pax_global_header00006660000000000000000000000064152056100140014504gustar00rootroot0000000000000052 comment=cf19de7fe8e1d704d1b9c31e23b22ef5ef9c9a9d fabriziomello-pg_stat_log-cf19de7/000077500000000000000000000000001520561001400173465ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/.clang-format000066400000000000000000000103351520561001400217230ustar00rootroot00000000000000--- Language: Cpp # BasedOnStyle: LLVM AccessModifierOffset: -2 AlignAfterOpenBracket: Align AlignArrayOfStructures: Left AlignConsecutiveAssignments: true AlignConsecutiveDeclarations: true AlignConsecutiveMacros: true AlignEscapedNewlines: Right AlignOperands: true AlignTrailingComments: true AllowAllParametersOfDeclarationOnNextLine: true AllowShortBlocksOnASingleLine: false AllowShortCaseLabelsOnASingleLine: false AllowShortFunctionsOnASingleLine: All AllowShortIfStatementsOnASingleLine: false AllowShortLoopsOnASingleLine: false # AlwaysBreakAfterDefinitionReturnType: None # option is deprecated AlwaysBreakAfterReturnType: AllDefinitions AlwaysBreakBeforeMultilineStrings: false AlwaysBreakTemplateDeclarations: MultiLine BinPackArguments: false BinPackParameters: true BraceWrapping: AfterCaseLabel: true AfterClass: true AfterControlStatement: true AfterEnum: true AfterFunction: true AfterNamespace: true AfterObjCDeclaration: true AfterStruct: true AfterUnion: true AfterExternBlock: true BeforeCatch: true BeforeElse: true IndentBraces: false SplitEmptyFunction: true SplitEmptyRecord: true SplitEmptyNamespace: true BreakBeforeBinaryOperators: None BreakBeforeBraces: Custom BreakBeforeInheritanceComma: false # N/A C++ BreakInheritanceList: BeforeColon BreakBeforeTernaryOperators: false BreakConstructorInitializersBeforeComma: false BreakConstructorInitializers: BeforeColon BreakAfterJavaFieldAnnotations: false # N/A Java BreakStringLiterals: true ColumnLimit: 100 #CommentPragmas: '^ TS Pragma:' #For future proofing CompactNamespaces: false # N/A c++ ConstructorInitializerAllOnOneLineOrOnePerLine: false # N/A C++ ConstructorInitializerIndentWidth: 40 # N/A C++ ContinuationIndentWidth: 4 Cpp11BracedListStyle: true # see catalog.c array struct assigns for an example DerivePointerAlignment: false # always use Right DisableFormat: false # haha # ExperimentalAutoDetectBinPacking: false #the docs say not to have this in config file FixNamespaceComments: true # N/A C++ ForEachMacros: - foreach - forboth - for_each_cell - for_both_cell - forthree IncludeBlocks: Preserve # separate include blocks will not be merged IncludeCategories: # we want to ensure c.h and postgres.h appear first - Regex: '.*' Priority: 1 - Regex: '^' Priority: -1 - Regex: '^' Priority: -1 IncludeIsMainRegex: '' # filename_ will be seen as the primary include IndentCaseLabels: true IndentPPDirectives: None # do not indent preprocessor directives after the '#' IndentWidth: 4 IndentWrappedFunctionNames: false # we do not indent the function name in the declaration JavaScriptQuotes: Double # N/A js JavaScriptWrapImports: true # N/A js KeepEmptyLinesAtTheStartOfBlocks: false MacroBlockBegin: '' # regex of macros that behave like '{' MacroBlockEnd: '' # regex of macros that behave like '}' MaxEmptyLinesToKeep: 1 NamespaceIndentation: None # N/A c++ ObjCBinPackProtocolList: Auto # N/A objC ObjCBlockIndentWidth: 2 # N/A objC ObjCSpaceAfterProperty: false # N/A objC ObjCSpaceBeforeProtocolList: true # N/A objC PenaltyBreakAssignment: 2 PenaltyBreakBeforeFirstCallParameter: 10000 PenaltyBreakComment: 300 PenaltyBreakFirstLessLess: 120 PenaltyBreakString: 1000 PenaltyBreakTemplateDeclaration: 10 PenaltyExcessCharacter: 1000000 PenaltyReturnTypeOnItsOwnLine: 60 PointerAlignment: Right # as in char *foo; ReflowComments: true # break up long comments into multiple lines SortIncludes: false # keep includes in the same order as we write them SortUsingDeclarations: false # N/A c++ SpaceAfterCStyleCast: true SpaceAfterTemplateKeyword: false # N/A c++ SpaceBeforeAssignmentOperators: true SpaceBeforeCpp11BracedList: false SpaceBeforeCtorInitializerColon: true # N/A c++ SpaceBeforeInheritanceColon: false # N/A c++ SpaceBeforeParens: ControlStatements SpaceBeforeRangeBasedForLoopColon: true # N/A C++ SpaceInEmptyParentheses: false SpacesBeforeTrailingComments: 1 SpacesInAngles: false # N/A c++ SpacesInContainerLiterals: true # N/A c++ SpacesInCStyleCastParentheses: false SpacesInParentheses: false SpacesInSquareBrackets: false Standard: Cpp11 TabWidth: 4 UseTab: Never ... fabriziomello-pg_stat_log-cf19de7/.github/000077500000000000000000000000001520561001400207065ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/.github/workflows/000077500000000000000000000000001520561001400227435ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/.github/workflows/code_style.yml000066400000000000000000000013041520561001400256160ustar00rootroot00000000000000name: Code style on: push: branches: [main] paths: - '**.c' - '**.h' - '.clang-format' pull_request: branches: [main] paths: - '**.c' - '**.h' - '.clang-format' jobs: clang-format: name: Check code formatting runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v5 - name: Check trailing whitespace if: always() run: | find . -type f -regex '.*\.\(c\|h\)$' -exec perl -pi -e 's/[ \t]+$//' {} + git diff --exit-code - name: Check code formatting if: always() run: | clang-format -i pg_stat_log.c git diff --exit-code fabriziomello-pg_stat_log-cf19de7/.github/workflows/installcheck.yml000066400000000000000000000011721520561001400261330ustar00rootroot00000000000000name: CI on: push: branches: [main] paths-ignore: - '**.md' - 'LICENSE' - '.gitignore' pull_request: branches: [main] paths-ignore: - '**.md' - 'LICENSE' - '.gitignore' jobs: test: runs-on: ubuntu-latest container: pgxn/pgxn-tools strategy: matrix: pg: [18, 19] steps: - name: Check out the repo uses: actions/checkout@v5 - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} - name: Build and test run: | chown -R postgres:postgres . gosu postgres pg-build-test fabriziomello-pg_stat_log-cf19de7/.gitignore000066400000000000000000000006061520561001400213400ustar00rootroot00000000000000# Global excludes across all subdirectories *.o *.obj *.bc *.so *.so.[0-9] *.so.[0-9].[0-9] *.so.[0-9].[0-9][0-9] *.dylib *.dll *.exp *.a *.mo *.pot objfiles.txt .deps/ *.gcno *.gcda *.gcov *.gcov.out lcov*.info coverage/ coverage-html-stamp *.vcproj *.vcxproj win32ver.rc *.exe lib*dll.def lib*.pc # Extension specific excludes /results/ /tmp_check/ /log/ /.idea/ pg_stat_log_errcodes.h fabriziomello-pg_stat_log-cf19de7/LICENSE000066400000000000000000000017601520561001400203570ustar00rootroot00000000000000PostgreSQL License Copyright (c) 2026, Fabrízio de Royes Mello Permission to use, copy, modify, and distribute this software and its documentation for any purpose, without fee, and without a written agreement is hereby granted, provided that the above copyright notice and this paragraph and the following two paragraphs appear in all copies. IN NO EVENT SHALL FABRÍZIO DE ROYES MELLO BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, EVEN IF FABRÍZIO DE ROYES MELLO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. FABRÍZIO DE ROYES MELLO SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, AND FABRÍZIO DE ROYES MELLO HAS NO OBLIGATIONS TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. fabriziomello-pg_stat_log-cf19de7/Makefile000066400000000000000000000016751520561001400210170ustar00rootroot00000000000000MODULE_big = pg_stat_log EXTENSION = pg_stat_log DATA = pg_stat_log--0.1.sql OBJS = pg_stat_log.o REGRESS_OPTS = --temp-instance=tmp_check --temp-config=pg_stat_log.conf REGRESS = pg_stat_log TAP_TESTS = 1 PG_CONFIG = pg_config PGXS := $(shell $(PG_CONFIG) --pgxs) include $(PGXS) # Check for the errcodes.txt on the standard Postgres distribution ERRCODES_TXT = $(shell $(PG_CONFIG) --sharedir)/errcodes.txt # Generate the errcode-name lookup header from errcodes.txt. pg_stat_log_errcodes.h: generate-errcode-names.pl @if [ -f "$(ERRCODES_FILE)" ]; then \ $(PERL) $< --outfile $@ $(ERRCODES_FILE); \ elif [ -f "$(ERRCODES_TXT)" ]; then \ $(PERL) $< --outfile $@ $(ERRCODES_TXT); \ else \ echo "ERROR: cannot find errcodes.txt; set ERRCODES_FILE=/path/to/errcodes.txt" >&2; \ exit 1; \ fi pg_stat_log.o: pg_stat_log_errcodes.h format: clang-format -i pg_stat_log.c clean: clean-errcodes clean-errcodes: rm -f pg_stat_log_errcodes.h fabriziomello-pg_stat_log-cf19de7/README.md000066400000000000000000000225001520561001400206240ustar00rootroot00000000000000# pg_stat_log [![CI](https://github.com/fabriziomello/pg_stat_log/actions/workflows/installcheck.yml/badge.svg)](https://github.com/fabriziomello/pg_stat_log/actions/workflows/installcheck.yml) Cumulative statistics about PostgreSQL log messages, built on the [Custom Cumulative Stats](https://wiki.postgresql.org/wiki/CustomCumulativeStats) API introduced in PostgreSQL 18. ## Overview `pg_stat_log` hooks into PostgreSQL's `emit_log_hook` to count log messages grouped by: - **backend type** -- client backend, autovacuum worker, checkpointer, etc. - **database** -- which database the message originated from - **user** -- which role was active - **error level** -- WARNING, ERROR, FATAL, PANIC, etc. - **SQLSTATE code** -- the 5-character error code and its human-readable name Counters are exposed through the `pg_stat_log` view and the underlying `pg_stat_log_data()` function. Statistics persist across clean restarts and are discarded after crash recovery, following standard PostgreSQL cumulative stats semantics. The extension uses custom stats kind ID **28**, registered on the [PostgreSQL wiki](https://wiki.postgresql.org/wiki/CustomCumulativeStats). ## Requirements - PostgreSQL 18 or later - Must be loaded via `shared_preload_libraries` ## Installation Build and install using PGXS: ```bash make make install ``` If the build cannot locate `errcodes.txt` automatically, point it to the PostgreSQL source tree: ```bash make ERRCODES_FILE=/path/to/postgresql/src/backend/utils/errcodes.txt ``` Then configure PostgreSQL to load the extension: ``` # postgresql.conf shared_preload_libraries = 'pg_stat_log' ``` Restart the server and create the extension: ```sql CREATE EXTENSION pg_stat_log; ``` ## Configuration | Parameter | Type | Default | Context | Description | |-----------|------|---------|---------|-------------| | `pg_stat_log.enabled` | bool | `true` | SUSET | Enable or disable collection at runtime | | `pg_stat_log.min_error_level` | enum | `warning` | SUSET | Minimum severity to track (`debug5` through `panic`) | | `pg_stat_log.max_entries` | int | `1024` | POSTMASTER | Maximum distinct combinations to track; requires restart | ## Usage ### Viewing statistics Query the `pg_stat_log` view: ```sql SELECT * FROM pg_stat_log; ``` ``` backend_type | database_oid | database_name | user_oid | user_name | elevel | sqlerrcode | sqlerrcode_name | count ----------------+--------------+---------------+----------+-----------+---------+------------+------------------+------- client backend | 16384 | mydb | 10 | postgres | WARNING | 01000 | warning | 42 client backend | 16384 | mydb | 10 | postgres | ERROR | 22012 | division_by_zero | 3 client backend | 16384 | mydb | 10 | postgres | ERROR | 42P01 | undefined_table | 7 ``` ### View columns | Column | Type | Description | |--------|------|-------------| | `backend_type` | text | Process type (e.g. `client backend`, `autovacuum worker`) | | `database_oid` | oid | Database OID, NULL for shared or background processes | | `database_name` | text | Resolved database name | | `user_oid` | oid | Role OID, NULL for background processes | | `user_name` | text | Resolved role name | | `elevel` | text | Error severity (WARNING, ERROR, FATAL, PANIC, ...) | | `sqlerrcode` | text | 5-character SQLSTATE code | | `sqlerrcode_name` | text | Condition name (e.g. `division_by_zero`) | | `count` | bigint | Cumulative message count | ### Example queries Top 10 most frequent errors: ```sql SELECT elevel, sqlerrcode, sqlerrcode_name, sum(count) AS total FROM pg_stat_log WHERE elevel = 'ERROR' GROUP BY elevel, sqlerrcode, sqlerrcode_name ORDER BY total DESC LIMIT 10; ``` Errors by database: ```sql SELECT database_name, elevel, count FROM pg_stat_log WHERE database_name IS NOT NULL ORDER BY count DESC; ``` Errors from background processes: ```sql SELECT backend_type, elevel, sqlerrcode_name, count FROM pg_stat_log WHERE backend_type <> 'client backend' ORDER BY count DESC; ``` ### Resetting statistics ```sql SELECT pg_stat_log_reset(); ``` Resetting also reclaims the tracked-combination slots, so new distinct combinations can be tracked after a reset even if `max_entries` had been reached previously. ### Inspecting capacity and drops `pg_stat_log_info()` exposes metadata about the tracking state: ```sql SELECT * FROM pg_stat_log_info(); ``` ``` max_entries | num_entries | n_dropped | stats_reset -------------+-------------+-----------+------------------------------- 1024 | 37 | 0 | 2026-04-22 16:40:39.124+00 ``` | Column | Type | Description | |--------|------|-------------| | `max_entries` | int | Configured `pg_stat_log.max_entries` capacity | | `num_entries` | int | Distinct combinations currently tracked | | `n_dropped` | bigint | Log messages that could not be tracked because `max_entries` was full | | `stats_reset` | timestamptz | Timestamp of the last reset (or shared-memory init) | Monitor `n_dropped` to detect when `max_entries` is too small for your workload: if it grows over time, increase `pg_stat_log.max_entries` (and restart) so every distinct combination fits. `stats_reset` advances whenever `pg_stat_log_reset()` is called. ## How it works `pg_stat_log` uses the fixed-amount Custom Cumulative Stats API, following the same pattern as PostgreSQL's in-tree `test_custom_fixed_stats` test module. 1. **Startup** (`_PG_init`): registers custom stats kind 28, defines GUCs, and installs the `emit_log_hook`. 2. **Hook** (`emit_log_hook`): on each log message at or above the configured minimum level, acquires a shared-memory LWLock, scans the entry array for a matching (backend_type, database, user, elevel, sqlerrcode) combination, and either increments an existing counter or creates a new entry. Uses the changecount protocol for atomic reads. 3. **pgstat callbacks**: three callbacks mirror the test module -- `init_shmem_cb` initializes the LWLock and array header, `reset_all_cb` zeroes counters and reclaims slots, `snapshot_cb` copies stats for reporting. 4. **Reporting**: `pg_stat_log_data()` is a set-returning C function that reads from the pgstat snapshot. The `pg_stat_log` view joins its output with `pg_database` and `pg_roles` to resolve OIDs to names. ## Caveats A few things to keep in mind when interpreting the counters: - **`emit_log_hook` only fires for messages that actually reach the server log.** Lowering `pg_stat_log.min_error_level` on its own is not enough -- `log_min_messages` acts as a floor on what the hook ever sees. For example, `pg_stat_log.min_error_level = 'notice'` requires `log_min_messages = 'notice'` (or lower, e.g. `info`, `debug1`) to have any effect. With the default `log_min_messages = 'warning'`, NOTICE and INFO messages are filtered out before the hook runs and will not be counted. - **NULL `database_name` / `user_name` rows are expected.** A log message can be emitted before a backend has bound to a database or role, so the database and user OIDs may be unset. Typical cases include authentication failures (e.g. FATAL with SQLSTATE `28P01`), early startup messages, and messages logged in the postmaster context. This is not a bug -- those rows record real events that simply have no database/user to attribute them to. - **Parallel workers appear as a separate `backend_type`.** If an error is raised during a parallel query, you may see one row with `backend_type = 'client backend'` (the leader) plus one row per parallel worker that hit the error with `backend_type = 'parallel worker'`. Keep this in mind when aggregating so you do not double-count a single logical error. - **`max_entries` is a hard cap.** Once the configured number of distinct (backend_type, database, user, elevel, sqlerrcode) combinations has been reached, new distinct combinations are dropped until `pg_stat_log_reset()` is called, which reclaims all slots. Monitor `n_dropped` via `pg_stat_log_info()` to detect when `max_entries` is too small. Size it to cover the cardinality you expect -- roughly `N_databases x N_roles x typical_distinct_sqlstates x backend_types` -- and remember it is `POSTMASTER`-context, so changes require a restart. - **`n_dropped` and `stats_reset` do not persist across restarts.** These metadata fields are reinitialized at server startup. The per-combination counters persist across clean restarts, but `n_dropped` resets to zero and `stats_reset` is set to the startup timestamp. This is by design -- drop telemetry measures pressure since the last startup or reset. - **Caught PL/pgSQL exceptions are not counted.** If a `RAISE` inside a `BEGIN ... EXCEPTION WHEN OTHERS` block is caught by the exception handler, the message never reaches the server log and `emit_log_hook` does not fire. Only messages that actually make it to the log are tracked. ## Files | File | Purpose | |------|---------| | `pg_stat_log.c` | C implementation (hook, callbacks, SQL functions) | | `pg_stat_log--1.0.sql` | SQL objects (functions, view) | | `pg_stat_log.control` | Extension metadata | | `Makefile` | PGXS build | | `generate-errcode-names.pl` | Generates SQLSTATE-to-name lookup header from `errcodes.txt` | | `t/001_pg_stat_log.pl` | TAP regression tests | ## License Released under the [PostgreSQL License](https://opensource.org/licenses/PostgreSQL). fabriziomello-pg_stat_log-cf19de7/expected/000077500000000000000000000000001520561001400211475ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/expected/pg_stat_log.out000066400000000000000000000077241520561001400242140ustar00rootroot00000000000000-- -- pg_stat_log regression tests -- CREATE EXTENSION pg_stat_log; -- Start clean SELECT pg_stat_log_reset(); pg_stat_log_reset ------------------- (1 row) SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) -- -- Test 1: Warnings are counted -- DO $$ BEGIN RAISE WARNING 'test warning 1'; END $$; WARNING: test warning 1 DO $$ BEGIN RAISE WARNING 'test warning 2'; END $$; WARNING: test warning 2 DO $$ BEGIN RAISE WARNING 'test warning 3'; END $$; WARNING: test warning 3 SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) SELECT count >= 3 AS warning_count_ok FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; warning_count_ok ------------------ t (1 row) -- -- Test 2: Errors are tracked -- SELECT 1/0; ERROR: division by zero SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) SELECT count >= 1 AS division_by_zero_ok FROM pg_stat_log_data() WHERE elevel = 'ERROR' AND sqlerrcode = '22012'; division_by_zero_ok --------------------- t (1 row) -- -- Test 3: pg_stat_log view works (returns rows with database/user names) -- SELECT count(*) > 0 AS view_has_rows FROM pg_stat_log WHERE count > 0; view_has_rows --------------- t (1 row) -- -- Test 4: Disable via GUC stops counting -- SET pg_stat_log.enabled = off; DO $$ BEGIN RAISE WARNING 'should not be counted'; END $$; WARNING: should not be counted SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) -- The warning count should not have increased; we check by looking for the -- specific message-related sqlerrcode that was already counted before. SELECT count >= 3 AS still_same_warning_count FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; still_same_warning_count -------------------------- t (1 row) SET pg_stat_log.enabled = on; -- -- Test 5: min_error_level filtering -- SET pg_stat_log.min_error_level = 'error'; -- Record warning count before SELECT count AS cnt_before FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000' \gset DO $$ BEGIN RAISE WARNING 'filtered out'; END $$; WARNING: filtered out SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) -- Warning count should be unchanged SELECT count = :cnt_before AS warning_filtered_ok FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; warning_filtered_ok --------------------- t (1 row) SET pg_stat_log.min_error_level = 'warning'; -- -- Test 6: Reset zeroes counters -- SELECT pg_stat_log_reset(); pg_stat_log_reset ------------------- (1 row) SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) SELECT COALESCE(sum(count), 0) = 0 AS reset_ok FROM pg_stat_log_data(); reset_ok ---------- t (1 row) -- -- Test 7: pg_stat_log_info() returns one row with expected columns -- SELECT count(*) = 1 AS info_one_row FROM pg_stat_log_info(); info_one_row -------------- t (1 row) -- -- Test 8: max_entries matches GUC -- SELECT max_entries = current_setting('pg_stat_log.max_entries')::int AS max_matches_guc FROM pg_stat_log_info(); max_matches_guc ----------------- t (1 row) -- -- Test 9: After reset, num_entries and n_dropped are zero -- SELECT pg_stat_log_reset(); pg_stat_log_reset ------------------- (1 row) SELECT pg_stat_force_next_flush(); pg_stat_force_next_flush -------------------------- (1 row) SELECT num_entries = 0 AS num_zero, n_dropped = 0 AS dropped_zero FROM pg_stat_log_info(); num_zero | dropped_zero ----------+-------------- t | t (1 row) -- -- Test 10: pg_stat_log_reset() is restricted to superusers -- CREATE ROLE regress_pg_stat_log_user; SET ROLE regress_pg_stat_log_user; SELECT pg_stat_log_reset(); ERROR: permission denied for function pg_stat_log_reset RESET ROLE; DROP ROLE regress_pg_stat_log_user; -- Clean up DROP EXTENSION pg_stat_log; fabriziomello-pg_stat_log-cf19de7/generate-errcode-names.pl000066400000000000000000000026771520561001400242330ustar00rootroot00000000000000#!/usr/bin/perl # # Generate pg_stat_log_errcodes.h from errcodes.txt # # Produces a static lookup table mapping packed sqlerrcode values to their # human-readable condition names (e.g. ERRCODE_DIVISION_BY_ZERO -> "division_by_zero"). # use strict; use warnings FATAL => 'all'; use Getopt::Long; my $outfile = ''; GetOptions('outfile=s' => \$outfile) or die "$0: wrong arguments"; open my $errcodes, '<', $ARGV[0] or die "$0: could not open input file '$ARGV[0]': $!\n"; my $outfh; if ($outfile) { open $outfh, '>', $outfile or die "$0: could not open output file '$outfile': $!\n"; } else { $outfh = *STDOUT; } print $outfh "/* autogenerated from errcodes.txt, do not edit */\n\n"; print $outfh "typedef struct PgStatLogErrCode\n"; print $outfh "{\n"; print $outfh "\tint\t\t\tsqlerrcode;\n"; print $outfh "\tconst char *name;\n"; print $outfh "} PgStatLogErrCode;\n\n"; print $outfh "static const PgStatLogErrCode pg_stat_log_errcodes[] = {\n"; while (<$errcodes>) { chomp; next if /^#/; next if /^\s*$/; next if /^Section:/; # Parse: sqlstate E/W/S ERRCODE_MACRO [spec_name] next unless /^([^\s]{5})\s+[EWS]\s+([^\s]+)(?:\s+([^\s]+))?/; my ($sqlstate, $errcode_macro, $spec_name) = ($1, $2, $3); # Skip entries without a spec_name next unless defined $spec_name && $spec_name ne ''; print $outfh "\t{$errcode_macro, \"$spec_name\"},\n"; } print $outfh "\t{0, NULL}\n"; print $outfh "};\n"; close $errcodes; close $outfh if ($outfile); fabriziomello-pg_stat_log-cf19de7/pg_stat_log--0.1.sql000066400000000000000000000031241520561001400227420ustar00rootroot00000000000000/* pg_stat_log/pg_stat_log--0.1.sql */ -- complain if script is sourced in psql, rather than via CREATE EXTENSION \echo Use "CREATE EXTENSION pg_stat_log" to load this file. \quit CREATE FUNCTION pg_stat_log_data( OUT backend_type text, OUT database_oid oid, OUT user_oid oid, OUT elevel text, OUT sqlerrcode text, OUT sqlerrcode_name text, OUT count bigint ) RETURNS SETOF record AS 'MODULE_PATHNAME', 'pg_stat_log_data' LANGUAGE C STRICT PARALLEL UNSAFE; CREATE FUNCTION pg_stat_log_reset() RETURNS void AS 'MODULE_PATHNAME', 'pg_stat_log_reset' LANGUAGE C STRICT PARALLEL UNSAFE; CREATE VIEW pg_stat_log AS SELECT s.backend_type, s.database_oid, d.datname AS database_name, s.user_oid, u.rolname AS user_name, s.elevel, s.sqlerrcode, s.sqlerrcode_name, s.count FROM pg_stat_log_data() s LEFT JOIN pg_database d ON d.oid = s.database_oid LEFT JOIN pg_roles u ON u.oid = s.user_oid; REVOKE ALL ON FUNCTION pg_stat_log_reset() FROM PUBLIC; REVOKE ALL ON FUNCTION pg_stat_log_data() FROM PUBLIC; GRANT EXECUTE ON FUNCTION pg_stat_log_data() TO pg_read_all_stats; REVOKE ALL ON pg_stat_log FROM PUBLIC; GRANT SELECT ON pg_stat_log TO pg_read_all_stats; CREATE FUNCTION pg_stat_log_info( OUT max_entries int, OUT num_entries int, OUT n_dropped bigint, OUT stats_reset timestamp with time zone ) RETURNS SETOF record AS 'MODULE_PATHNAME', 'pg_stat_log_info' LANGUAGE C STRICT PARALLEL UNSAFE; REVOKE ALL ON FUNCTION pg_stat_log_info() FROM PUBLIC; GRANT EXECUTE ON FUNCTION pg_stat_log_info() TO pg_read_all_stats; fabriziomello-pg_stat_log-cf19de7/pg_stat_log.c000066400000000000000000000451771520561001400220320ustar00rootroot00000000000000/*-------------------------------------------------------------------------- * * pg_stat_log.c * Cumulative statistics about log messages. * * Hooks into emit_log_hook to count log messages grouped by * (elevel, sqlerrcode, database_oid, user_oid, backend_type). * * Uses the fixed-amount Custom Cumulative Stats API introduced in * PostgreSQL 18, following the same pattern as test_custom_fixed_stats.c. * * Copyright (c) 2026, PlanetScale Inc. * * IDENTIFICATION * pg_stat_log/pg_stat_log.c * *-------------------------------------------------------------------------- */ #include "postgres.h" #include "funcapi.h" #include "miscadmin.h" #include "pgstat.h" #include "common/hashfn.h" #include "storage/ipc.h" #include "storage/proc.h" #include "utils/builtins.h" #include "utils/errcodes.h" #include "utils/guc.h" #include "utils/pgstat_internal.h" #include "utils/timestamp.h" #include "utils/tuplestore.h" #include "pg_stat_log_errcodes.h" #define PGSTAT_LOG_MODULE_NAME "pg_stat_log" #define PGSTAT_LOG_TRANCHE_NAME PGSTAT_LOG_MODULE_NAME PG_MODULE_MAGIC_EXT(.name = PGSTAT_LOG_MODULE_NAME, .version = PG_VERSION); /* * Custom stats kind ID — registered at * https://wiki.postgresql.org/wiki/CustomCumulativeStats */ #define PGSTAT_KIND_LOG 28 /* GUC defaults and bounds */ #define PGSTAT_LOG_MAX_DEFAULT 1024 #define PGSTAT_LOG_MIN_ENTRIES 64 #define PGSTAT_LOG_MAX_ENTRIES (PGSTAT_LOG_MAX_DEFAULT * PGSTAT_LOG_MAX_DEFAULT) /* * Data structures */ typedef struct PgStatLogSlot { bool used; BackendType backend_type; Oid dboid; Oid userid; int elevel; int sqlerrcode; PgStat_Counter count; } PgStatLogSlot; /* * Stats data block. Variable-length: header followed by entries[max]. */ typedef struct PgStatLog { int max_entries; int num_entries; PgStatLogSlot entries[FLEXIBLE_ARRAY_MEMBER]; } PgStatLog; /* * Shared memory wrapper. LWLock + changecount + metadata + data. Metadata * fields (stat_reset_timestamp, n_dropped) live outside the copied stats * block so they can be read directly under the LWLock without the * changecount protocol. The data area holds one PgStatLog block. */ typedef struct PgStatLogShared { LWLock lock; uint32 changecount; TimestampTz stat_reset_timestamp; uint64 n_dropped; char data[FLEXIBLE_ARRAY_MEMBER]; } PgStatLogShared; /* * GUC variables */ static bool pg_stat_log_enabled = true; static int pg_stat_log_min_elevel = WARNING; static int pg_stat_log_max = PGSTAT_LOG_MAX_DEFAULT; /* * Computed sizes (set in _PG_init based on pg_stat_log.max_entries) */ static Size stats_block_size; /* one PgStatLog block */ /* * Hook state */ static shmem_startup_hook_type prev_shmem_startup_hook = NULL; static emit_log_hook_type prev_emit_log_hook = NULL; static bool in_emit_log_hook = false; /* * Accessor helpers */ static inline PgStatLog * pg_stat_log_get_stats(PgStatLogShared *shmem) { return (PgStatLog *) shmem->data; } /* * Errcode name lookup */ static const char * pg_stat_log_errcode_name(int sqlerrcode) { for (int i = 0; pg_stat_log_errcodes[i].name != NULL; i++) { if (pg_stat_log_errcodes[i].sqlerrcode == sqlerrcode) return pg_stat_log_errcodes[i].name; } return NULL; } /* * Hash function for slot lookup — combines all key fields into a uint32 * for open-addressing into the entries array. */ static inline uint32 pg_stat_log_hash_key(BackendType backend_type, Oid dboid, Oid userid, int elevel, int sqlerrcode) { uint32 h; h = murmurhash32((uint32) backend_type); h = hash_combine(h, murmurhash32((uint32) dboid)); h = hash_combine(h, murmurhash32((uint32) userid)); h = hash_combine(h, murmurhash32((uint32) elevel)); h = hash_combine(h, murmurhash32((uint32) sqlerrcode)); return h; } /* * PgStat_KindInfo — filled dynamically in _PG_init */ static void pg_stat_log_init_backend_cb(void); static void pg_stat_log_init_shmem_cb(void *stats); static void pg_stat_log_reset_all_cb(TimestampTz ts); static void pg_stat_log_snapshot_cb(void); static PgStat_KindInfo log_stats_kind; /* * init_backend_cb — per-backend initialization * * Runs in every backend after the stats file has been loaded by the * startup process. Validates that persisted max_entries matches the * current GUC. If pg_stat_log.max_entries was changed across a clean * restart, the restored stats block has a stale layout — discard and * reinitialize to prevent out-of-bounds access. */ static void pg_stat_log_init_backend_cb(void) { PgStatLogShared *shmem; PgStatLog *s; shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); s = pg_stat_log_get_stats(shmem); if (s->max_entries != pg_stat_log_max) { elog(LOG, "pg_stat_log: discarding persisted stats " "(max_entries changed from %d to %d)", s->max_entries, pg_stat_log_max); LWLockAcquire(&shmem->lock, LW_EXCLUSIVE); pgstat_begin_changecount_write(&shmem->changecount); s->max_entries = pg_stat_log_max; s->num_entries = 0; MemSet(s->entries, 0, (Size) pg_stat_log_max * sizeof(PgStatLogSlot)); pgstat_end_changecount_write(&shmem->changecount); shmem->stat_reset_timestamp = GetCurrentTimestamp(); shmem->n_dropped = 0; LWLockRelease(&shmem->lock); } } /* * init_shmem_cb — initialize shared memory * * Only runs in the postmaster (see StatsShmemInit), after the main LWLock * array has been set up. This is the first place it is safe to call * LWLockNewTrancheId(), which needs shared memory to exist. */ static void pg_stat_log_init_shmem_cb(void *stats) { PgStatLogShared *shmem = (PgStatLogShared *) stats; PgStatLog *s; #if PG_VERSION_NUM < 190000 LWLockInitialize(&shmem->lock, LWLockNewTrancheId()); #else LWLockInitialize(&shmem->lock, LWLockNewTrancheId(PGSTAT_LOG_TRANCHE_NAME)); #endif shmem->stat_reset_timestamp = GetCurrentTimestamp(); shmem->n_dropped = 0; s = pg_stat_log_get_stats(shmem); s->max_entries = pg_stat_log_max; s->num_entries = 0; } /* * shmem_startup_hook — register the tranche name in every process. * * On fork-based platforms backends inherit the tranche ID and the * registration from the postmaster, but under EXEC_BACKEND each child has * to re-register, so do it unconditionally here. The tranche ID is read * from shared memory (initialized by the init_shmem_cb above). */ static void pg_stat_log_shmem_startup(void) { if (prev_shmem_startup_hook) prev_shmem_startup_hook(); #if PG_VERSION_NUM < 190000 { PgStatLogShared *shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); LWLockRegisterTranche(shmem->lock.tranche, PGSTAT_LOG_TRANCHE_NAME); } #endif } /* * reset_all_cb — reset statistics * * Zero the stats block (slot array + num_entries) so that slots are * reclaimed for reuse. Otherwise, once max_entries is reached, a reset * would not free capacity for new distinct combinations. */ static void pg_stat_log_reset_all_cb(TimestampTz ts) { PgStatLogShared *shmem; PgStatLog *s; shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); s = pg_stat_log_get_stats(shmem); LWLockAcquire(&shmem->lock, LW_EXCLUSIVE); pgstat_begin_changecount_write(&shmem->changecount); MemSet(s->entries, 0, (Size) s->max_entries * sizeof(PgStatLogSlot)); s->num_entries = 0; pgstat_end_changecount_write(&shmem->changecount); shmem->stat_reset_timestamp = ts; shmem->n_dropped = 0; LWLockRelease(&shmem->lock); } /* * snapshot_cb — build snapshot for reads */ static void pg_stat_log_snapshot_cb(void) { PgStatLogShared *shmem; PgStatLog *snap; shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); snap = (PgStatLog *) pgstat_get_custom_snapshot_data(PGSTAT_KIND_LOG); /* Copy current stats via changecount protocol */ pgstat_copy_changecounted_stats(snap, pg_stat_log_get_stats(shmem), stats_block_size, &shmem->changecount); } /* * pg_stat_log_count_message — record one log message in shared memory * * Acquires the LWLock, does an O(1) hash probe to find or create the * entry, increments the counter, and releases the lock. */ static void pg_stat_log_count_message(ErrorData *edata) { PgStatLogShared *shmem; PgStatLog *s; Oid dboid; Oid userid; int sec_context; uint32 hash; uint32 idx; int probe; shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); dboid = MyDatabaseId; GetUserIdAndSecContext(&userid, &sec_context); LWLockAcquire(&shmem->lock, LW_EXCLUSIVE); s = pg_stat_log_get_stats(shmem); hash = pg_stat_log_hash_key(MyBackendType, dboid, userid, edata->elevel, edata->sqlerrcode); idx = hash % s->max_entries; for (probe = 0; probe < s->max_entries; probe++) { uint32 pos = (idx + probe) % s->max_entries; PgStatLogSlot *slot = &s->entries[pos]; if (!slot->used) { if (s->num_entries < s->max_entries) { pgstat_begin_changecount_write(&shmem->changecount); slot->used = true; slot->backend_type = MyBackendType; slot->dboid = dboid; slot->userid = userid; slot->elevel = edata->elevel; slot->sqlerrcode = edata->sqlerrcode; slot->count = 1; s->num_entries++; pgstat_end_changecount_write(&shmem->changecount); } else { shmem->n_dropped++; } break; } if (slot->backend_type == MyBackendType && slot->dboid == dboid && slot->userid == userid && slot->elevel == edata->elevel && slot->sqlerrcode == edata->sqlerrcode) { pgstat_begin_changecount_write(&shmem->changecount); slot->count++; pgstat_end_changecount_write(&shmem->changecount); break; } } /* Full table scan without match — all slots occupied by other keys */ if (probe == s->max_entries) shmem->n_dropped++; LWLockRelease(&shmem->lock); } /* * emit_log_hook — intercept log messages for counting * * Always forwards to the previous hook in the chain. Only counts the * message if pg_stat_log is enabled and the severity meets the threshold. */ static void pg_stat_log_emit_hook(ErrorData *edata) { if (in_emit_log_hook) return; /* * pgstat shared memory might not be set up yet during early startup or * in auxiliary processes before attachment. */ if ((!IsUnderPostmaster && IsPostmasterEnvironment) || !MyProc) return; in_emit_log_hook = true; PG_TRY(); { if (prev_emit_log_hook) prev_emit_log_hook(edata); if (pg_stat_log_enabled && edata->elevel >= pg_stat_log_min_elevel) pg_stat_log_count_message(edata); } PG_FINALLY(); { in_emit_log_hook = false; } PG_END_TRY(); } /* * server_message_level_options was introduced in commit * https://github.com/postgres/postgres/commit/0a20ff54f5e6 * but only exported for use by extensions in * https://github.com/postgres/postgres/commit/38e0190ced71, * which will ship in PostgreSQL 19. */ #if PG_VERSION_NUM < 190000 static const struct config_enum_entry server_message_level_options[] = { {"debug5", DEBUG5, false}, {"debug4", DEBUG4, false}, {"debug3", DEBUG3, false}, {"debug2", DEBUG2, false}, {"debug1", DEBUG1, false}, {"debug", DEBUG2, true }, {"info", INFO, false}, {"notice", NOTICE, false}, {"warning", WARNING, false}, {"error", ERROR, false}, {"log", LOG, false}, {"fatal", FATAL, false}, {"panic", PANIC, false}, {NULL, 0, false} }; #endif /* * Module initialization */ void _PG_init(void) { Size shared_size; if (!process_shared_preload_libraries_in_progress) ereport(ERROR, (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), errmsg("pg_stat_log must be loaded via " "shared_preload_libraries"))); /* Define GUCs before computing sizes */ DefineCustomBoolVariable("pg_stat_log.enabled", "Enable collection of log statistics.", NULL, &pg_stat_log_enabled, true, PGC_SUSET, 0, NULL, NULL, NULL); DefineCustomEnumVariable("pg_stat_log.min_error_level", "Minimum error level to track.", NULL, &pg_stat_log_min_elevel, WARNING, server_message_level_options, PGC_SUSET, 0, NULL, NULL, NULL); DefineCustomIntVariable("pg_stat_log.max_entries", "Maximum number of distinct log entry " "combinations to track.", NULL, &pg_stat_log_max, PGSTAT_LOG_MAX_DEFAULT, PGSTAT_LOG_MIN_ENTRIES, PGSTAT_LOG_MAX_ENTRIES, PGC_POSTMASTER, 0, NULL, NULL, NULL); MarkGUCPrefixReserved("pg_stat_log"); /* Compute sizes based on pg_stat_log.max_entries */ stats_block_size = offsetof(PgStatLog, entries) + (Size) pg_stat_log_max * sizeof(PgStatLogSlot); shared_size = offsetof(PgStatLogShared, data) + stats_block_size; /* Fill in the KindInfo struct — use memcpy because .name is const */ { PgStat_KindInfo tmp = { .name = "pg_stat_log", .fixed_amount = true, .write_to_file = true, .shared_size = shared_size, .shared_data_off = offsetof(PgStatLogShared, data), .shared_data_len = stats_block_size, .init_backend_cb = pg_stat_log_init_backend_cb, .init_shmem_cb = pg_stat_log_init_shmem_cb, .reset_all_cb = pg_stat_log_reset_all_cb, .snapshot_cb = pg_stat_log_snapshot_cb, }; memcpy(&log_stats_kind, &tmp, sizeof(PgStat_KindInfo)); } pgstat_register_kind(PGSTAT_KIND_LOG, &log_stats_kind); /* * Install shmem_startup_hook so every process registers the LWLock * tranche name (needed for EXEC_BACKEND; harmless on fork). */ prev_shmem_startup_hook = shmem_startup_hook; shmem_startup_hook = pg_stat_log_shmem_startup; /* Install emit_log_hook */ prev_emit_log_hook = emit_log_hook; emit_log_hook = pg_stat_log_emit_hook; } /* * SQL-callable functions */ PG_FUNCTION_INFO_V1(pg_stat_log_data); /* * pg_stat_log_data() * Return all tracked log statistics as a set of rows. */ Datum pg_stat_log_data(PG_FUNCTION_ARGS) { ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; PgStatLog *snap; int i; pgstat_snapshot_fixed(PGSTAT_KIND_LOG); snap = (PgStatLog *) pgstat_get_custom_snapshot_data(PGSTAT_KIND_LOG); InitMaterializedSRF(fcinfo, 0); for (i = 0; i < snap->max_entries; i++) { Datum values[7]; bool nulls[7] = {0}; PgStatLogSlot *slot = &snap->entries[i]; const char *errname; if (!slot->used) continue; if (slot->count <= 0) continue; values[0] = CStringGetTextDatum(GetBackendTypeDesc(slot->backend_type)); if (OidIsValid(slot->dboid)) values[1] = ObjectIdGetDatum(slot->dboid); else nulls[1] = true; if (OidIsValid(slot->userid)) values[2] = ObjectIdGetDatum(slot->userid); else nulls[2] = true; values[3] = CStringGetTextDatum(error_severity(slot->elevel)); values[4] = CStringGetTextDatum(unpack_sql_state(slot->sqlerrcode)); errname = pg_stat_log_errcode_name(slot->sqlerrcode); if (errname) values[5] = CStringGetTextDatum(errname); else nulls[5] = true; values[6] = Int64GetDatum(slot->count); tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); } return (Datum) 0; } PG_FUNCTION_INFO_V1(pg_stat_log_reset); /* * pg_stat_log_reset() * Reset all tracked log statistics. */ Datum pg_stat_log_reset(PG_FUNCTION_ARGS) { pgstat_reset_of_kind(PGSTAT_KIND_LOG); PG_RETURN_VOID(); } PG_FUNCTION_INFO_V1(pg_stat_log_info); /* * pg_stat_log_info() * Return metadata about the pg_stat_log shared memory area: * max_entries capacity, current num_entries, number of dropped * messages due to capacity, and the last reset timestamp. */ Datum pg_stat_log_info(PG_FUNCTION_ARGS) { ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo; PgStatLogShared *shmem; PgStatLog *s; Datum values[4]; bool nulls[4] = {0}; int max_entries; int num_entries; uint64 n_dropped; TimestampTz stat_reset_timestamp; InitMaterializedSRF(fcinfo, 0); shmem = (PgStatLogShared *) pgstat_get_custom_shmem_data(PGSTAT_KIND_LOG); s = pg_stat_log_get_stats(shmem); LWLockAcquire(&shmem->lock, LW_SHARED); max_entries = s->max_entries; num_entries = s->num_entries; n_dropped = shmem->n_dropped; stat_reset_timestamp = shmem->stat_reset_timestamp; LWLockRelease(&shmem->lock); values[0] = Int32GetDatum(max_entries); values[1] = Int32GetDatum(num_entries); values[2] = Int64GetDatum((int64) n_dropped); values[3] = TimestampTzGetDatum(stat_reset_timestamp); tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); return (Datum) 0; } fabriziomello-pg_stat_log-cf19de7/pg_stat_log.conf000066400000000000000000000000511520561001400225130ustar00rootroot00000000000000shared_preload_libraries = 'pg_stat_log' fabriziomello-pg_stat_log-cf19de7/pg_stat_log.control000066400000000000000000000002101520561001400232430ustar00rootroot00000000000000comment = 'cumulative statistics about log messages' default_version = '0.1' module_pathname = '$libdir/pg_stat_log' relocatable = true fabriziomello-pg_stat_log-cf19de7/sql/000077500000000000000000000000001520561001400201455ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/sql/pg_stat_log.sql000066400000000000000000000053501520561001400231730ustar00rootroot00000000000000-- -- pg_stat_log regression tests -- CREATE EXTENSION pg_stat_log; -- Start clean SELECT pg_stat_log_reset(); SELECT pg_stat_force_next_flush(); -- -- Test 1: Warnings are counted -- DO $$ BEGIN RAISE WARNING 'test warning 1'; END $$; DO $$ BEGIN RAISE WARNING 'test warning 2'; END $$; DO $$ BEGIN RAISE WARNING 'test warning 3'; END $$; SELECT pg_stat_force_next_flush(); SELECT count >= 3 AS warning_count_ok FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; -- -- Test 2: Errors are tracked -- SELECT 1/0; SELECT pg_stat_force_next_flush(); SELECT count >= 1 AS division_by_zero_ok FROM pg_stat_log_data() WHERE elevel = 'ERROR' AND sqlerrcode = '22012'; -- -- Test 3: pg_stat_log view works (returns rows with database/user names) -- SELECT count(*) > 0 AS view_has_rows FROM pg_stat_log WHERE count > 0; -- -- Test 4: Disable via GUC stops counting -- SET pg_stat_log.enabled = off; DO $$ BEGIN RAISE WARNING 'should not be counted'; END $$; SELECT pg_stat_force_next_flush(); -- The warning count should not have increased; we check by looking for the -- specific message-related sqlerrcode that was already counted before. SELECT count >= 3 AS still_same_warning_count FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; SET pg_stat_log.enabled = on; -- -- Test 5: min_error_level filtering -- SET pg_stat_log.min_error_level = 'error'; -- Record warning count before SELECT count AS cnt_before FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000' \gset DO $$ BEGIN RAISE WARNING 'filtered out'; END $$; SELECT pg_stat_force_next_flush(); -- Warning count should be unchanged SELECT count = :cnt_before AS warning_filtered_ok FROM pg_stat_log_data() WHERE elevel = 'WARNING' AND sqlerrcode = '01000'; SET pg_stat_log.min_error_level = 'warning'; -- -- Test 6: Reset zeroes counters -- SELECT pg_stat_log_reset(); SELECT pg_stat_force_next_flush(); SELECT COALESCE(sum(count), 0) = 0 AS reset_ok FROM pg_stat_log_data(); -- -- Test 7: pg_stat_log_info() returns one row with expected columns -- SELECT count(*) = 1 AS info_one_row FROM pg_stat_log_info(); -- -- Test 8: max_entries matches GUC -- SELECT max_entries = current_setting('pg_stat_log.max_entries')::int AS max_matches_guc FROM pg_stat_log_info(); -- -- Test 9: After reset, num_entries and n_dropped are zero -- SELECT pg_stat_log_reset(); SELECT pg_stat_force_next_flush(); SELECT num_entries = 0 AS num_zero, n_dropped = 0 AS dropped_zero FROM pg_stat_log_info(); -- -- Test 10: pg_stat_log_reset() is restricted to superusers -- CREATE ROLE regress_pg_stat_log_user; SET ROLE regress_pg_stat_log_user; SELECT pg_stat_log_reset(); RESET ROLE; DROP ROLE regress_pg_stat_log_user; -- Clean up DROP EXTENSION pg_stat_log; fabriziomello-pg_stat_log-cf19de7/t/000077500000000000000000000000001520561001400176115ustar00rootroot00000000000000fabriziomello-pg_stat_log-cf19de7/t/001_pg_stat_log.pl000066400000000000000000000163131520561001400230340ustar00rootroot00000000000000# Copyright (c) 2026, Fabrizio de Royes Mello # Test pg_stat_log persistence behavior # # These tests require server restart/crash and cannot be covered by # regular regression tests. # # Verifies: # - Stats persist across clean restart # - Stats are lost after crash recovery use strict; use warnings FATAL => 'all'; use PostgreSQL::Test::Cluster; use PostgreSQL::Test::Utils; use Test::More; my $node = PostgreSQL::Test::Cluster->new('main'); $node->init; $node->append_conf('postgresql.conf', "shared_preload_libraries = 'pg_stat_log'"); $node->append_conf('postgresql.conf', "pg_stat_log.min_error_level = 'warning'"); $node->start; $node->safe_psql('postgres', q(CREATE EXTENSION pg_stat_log)); # Generate some data to persist $node->safe_psql('postgres', q( DO $$ BEGIN RAISE WARNING 'persist test'; END $$; )); $node->psql('postgres', q(SELECT 1/0)); $node->safe_psql('postgres', q(SELECT pg_stat_force_next_flush())); my $result = $node->safe_psql('postgres', q( SELECT count FROM pg_stat_log_data() WHERE elevel = 'ERROR' AND sqlerrcode = '22012' )); my $error_count_pre_restart = $result; # --------------------------------------------------------------- # Test 1: Stats persist across clean restart # --------------------------------------------------------------- $node->stop; $node->start; $result = $node->safe_psql('postgres', q( SELECT count FROM pg_stat_log_data() WHERE elevel = 'ERROR' AND sqlerrcode = '22012' )); is($result, $error_count_pre_restart, "error count persists after clean restart"); # --------------------------------------------------------------- # Test 1b: n_dropped and stats_reset do not persist across restart # --------------------------------------------------------------- my $n_dropped = $node->safe_psql('postgres', q( SELECT n_dropped FROM pg_stat_log_info() )); is($n_dropped, "0", "n_dropped resets to 0 after clean restart"); my $reset_ts = $node->safe_psql('postgres', q( SELECT stats_reset FROM pg_stat_log_info() )); ok(defined $reset_ts && $reset_ts ne '', "stats_reset is set to startup timestamp after clean restart"); # --------------------------------------------------------------- # Test 2: Stats lost after crash recovery # --------------------------------------------------------------- $node->stop('immediate'); $node->start; $result = $node->safe_psql('postgres', q( SELECT COALESCE(sum(count), 0) FROM pg_stat_log_data() )); is($result, "0", "all counts are zero after crash recovery"); # --------------------------------------------------------------- # Test 3: pg_stat_log_info() basics # --------------------------------------------------------------- my $info_rows = $node->safe_psql('postgres', q( SELECT count(*) FROM pg_stat_log_info() )); is($info_rows, "1", "pg_stat_log_info() returns one row"); my $max_entries = $node->safe_psql('postgres', q( SELECT max_entries FROM pg_stat_log_info() )); my $guc_max = $node->safe_psql('postgres', q(SHOW pg_stat_log.max_entries)); is($max_entries, $guc_max, "pg_stat_log_info.max_entries matches GUC pg_stat_log.max_entries"); # --------------------------------------------------------------- # Test 4: stats_reset advances on reset # --------------------------------------------------------------- my $reset_before = $node->safe_psql('postgres', q( SELECT extract(epoch FROM stats_reset)::numeric FROM pg_stat_log_info() )); $node->safe_psql('postgres', q(SELECT pg_sleep(0.1); SELECT pg_stat_log_reset();)); my $reset_after = $node->safe_psql('postgres', q( SELECT extract(epoch FROM stats_reset)::numeric FROM pg_stat_log_info() )); ok($reset_after > $reset_before, "stats_reset timestamp advances after pg_stat_log_reset()"); # --------------------------------------------------------------- # Test 5: n_dropped increments and reset reclaims slots # --------------------------------------------------------------- $node->stop('immediate'); $node->append_conf('postgresql.conf', "pg_stat_log.max_entries = 64"); $node->start; $max_entries = $node->safe_psql('postgres', q( SELECT max_entries FROM pg_stat_log_info() )); is($max_entries, "64", "max_entries reflects restart-scoped GUC"); # Generate 100 distinct SQLSTATE codes to overflow the 64-slot capacity $node->safe_psql('postgres', q{ DO $$ DECLARE i int; code text; BEGIN FOR i IN 1..100 LOOP code := 'Z' || lpad(i::text, 4, '0'); BEGIN RAISE WARNING 'overflow test %', i USING ERRCODE = code; EXCEPTION WHEN OTHERS THEN NULL; END; END LOOP; END $$; }); $node->safe_psql('postgres', q(SELECT pg_stat_force_next_flush())); my $num_entries = $node->safe_psql('postgres', q( SELECT num_entries FROM pg_stat_log_info() )); is($num_entries, "64", "num_entries saturates at max_entries"); $n_dropped = $node->safe_psql('postgres', q( SELECT n_dropped FROM pg_stat_log_info() )); ok($n_dropped > 0, "n_dropped > 0 after overflowing max_entries"); # Reset should reclaim slots $node->safe_psql('postgres', q(SELECT pg_stat_log_reset())); $num_entries = $node->safe_psql('postgres', q( SELECT num_entries FROM pg_stat_log_info() )); is($num_entries, "0", "num_entries is 0 after reset when saturated"); $n_dropped = $node->safe_psql('postgres', q( SELECT n_dropped FROM pg_stat_log_info() )); is($n_dropped, "0", "n_dropped is 0 after reset"); # Generate a NEW distinct error and verify it is tracked (slot reclaimed) $node->safe_psql('postgres', q{ DO $$ BEGIN RAISE WARNING 'post-reset' USING ERRCODE = 'Z9999'; EXCEPTION WHEN OTHERS THEN NULL; END $$; }); $node->safe_psql('postgres', q(SELECT pg_stat_force_next_flush())); my $post_reset = $node->safe_psql('postgres', q( SELECT count FROM pg_stat_log_data() WHERE sqlerrcode = 'Z9999' )); is($post_reset, "1", "new distinct error is tracked after reset (slots reclaimed)"); # --------------------------------------------------------------- # Test 6: Stats discarded when max_entries changes across restart # --------------------------------------------------------------- # Generate some stats with the current max_entries=64 $node->safe_psql('postgres', q(SELECT pg_stat_log_reset())); $node->safe_psql('postgres', q{ DO $$ BEGIN RAISE WARNING 'before resize'; END $$; }); $node->safe_psql('postgres', q(SELECT pg_stat_force_next_flush())); $num_entries = $node->safe_psql('postgres', q( SELECT num_entries FROM pg_stat_log_info() )); ok($num_entries > 0, "have entries before max_entries change"); # Clean restart with a different max_entries $node->stop; $node->append_conf('postgresql.conf', "pg_stat_log.max_entries = 128"); $node->start; # Stats should have been discarded due to capacity mismatch $max_entries = $node->safe_psql('postgres', q( SELECT max_entries FROM pg_stat_log_info() )); is($max_entries, "128", "max_entries reflects new GUC after restart"); $num_entries = $node->safe_psql('postgres', q( SELECT num_entries FROM pg_stat_log_info() )); is($num_entries, "0", "persisted stats discarded after max_entries change"); # Verify new entries can be tracked with the new capacity $node->safe_psql('postgres', q{ DO $$ BEGIN RAISE WARNING 'after resize'; END $$; }); $node->safe_psql('postgres', q(SELECT pg_stat_force_next_flush())); $num_entries = $node->safe_psql('postgres', q( SELECT num_entries FROM pg_stat_log_info() )); ok($num_entries > 0, "new entries tracked after max_entries change"); done_testing();