@@ -187,6 +187,68 @@ def smart_truncate_query(query: str, max_length: int = 40) -> str:
187187# Prometheus connection - use environment variable with fallback
188188PROMETHEUS_URL = os .environ .get ('PROMETHEUS_URL' , 'http://localhost:8428' )
189189
190+ # Retention: how long to keep stale queryid entries (queryids no longer in pg_stat_statements)
191+ QUERYID_RETENTION_HOURS = int (os .environ .get ('QUERYID_RETENTION_HOURS' , '24' ))
192+
193+ # How recent a queryid must be to appear in /query_info_metrics (must be > pgwatch collection interval)
194+ QUERYID_ACTIVE_MINUTES = int (os .environ .get ('QUERYID_ACTIVE_MINUTES' , '10' ))
195+
196+
197+ def _apply_trigger_migration ():
198+ """
199+ Apply trigger migration: update queryid timestamps on duplicate instead of
200+ silently skipping. This lets us distinguish active queryids (recently seen in
201+ pg_stat_statements) from stale ones, enabling time-based filtering and retention.
202+
203+ Safe to call multiple times (CREATE OR REPLACE FUNCTION is idempotent).
204+ """
205+ conn = None
206+ try :
207+ conn = psycopg2 .connect (POSTGRES_SINK_URL )
208+ conn .autocommit = True
209+ with conn .cursor () as cursor :
210+ cursor .execute ("""
211+ CREATE OR REPLACE FUNCTION enforce_queryid_uniqueness()
212+ RETURNS trigger AS $$
213+ DECLARE
214+ queryid_value text;
215+ BEGIN
216+ queryid_value := new.data->>'queryid';
217+
218+ IF queryid_value IS NULL THEN
219+ RETURN new;
220+ END IF;
221+
222+ -- If duplicate exists, refresh its timestamp (marks queryid as
223+ -- still active in pg_stat_statements) and skip the INSERT
224+ UPDATE pgss_queryid_queries
225+ SET time = new.time
226+ WHERE dbname = new.dbname
227+ AND data->>'queryid' = queryid_value;
228+
229+ IF FOUND THEN
230+ RETURN NULL;
231+ END IF;
232+
233+ RETURN new;
234+ END;
235+ $$ LANGUAGE plpgsql;
236+ """ )
237+ logger .info ("Applied trigger migration: enforce_queryid_uniqueness now refreshes timestamps" )
238+ except Exception as e :
239+ logger .warning (
240+ f"Failed to apply trigger migration: { e } . "
241+ "If 'must be owner' error, run with postgres superuser: "
242+ "ALTER FUNCTION enforce_queryid_uniqueness() OWNER TO pgwatch;"
243+ )
244+ finally :
245+ if conn :
246+ conn .close ()
247+
248+
249+ # Apply trigger migration at startup (idempotent)
250+ _apply_trigger_migration ()
251+
190252# Metric name mapping for cleaner CSV output
191253METRIC_NAME_MAPPING = {
192254 'calls' : 'calls' ,
@@ -230,17 +292,22 @@ def get_prometheus_client():
230292 raise
231293
232294
233- def get_query_texts_from_sink (db_name : str = None , truncation_mode : str = 'smart' ) -> dict :
295+ def get_query_texts_from_sink (db_name : str = None , truncation_mode : str = 'smart' ,
296+ max_age_hours : int = None ) -> dict :
234297 """
235298 Fetch queryid-to-query text mappings from the PostgreSQL sink database.
236299
237300 Args:
238301 db_name: Optional database name to filter results
239302 truncation_mode: 'smart' for smart truncation, 'raw' for simple truncation
303+ max_age_hours: Only return queryids seen within this many hours (None = use retention window)
240304
241305 Returns:
242306 Dictionary mapping queryid to query text
243307 """
308+ if max_age_hours is None :
309+ max_age_hours = QUERYID_RETENTION_HOURS
310+
244311 query_texts = {}
245312
246313 conn = None
@@ -257,23 +324,25 @@ def get_query_texts_from_sink(db_name: str = None, truncation_mode: str = 'smart
257324 FROM public.pgss_queryid_queries
258325 WHERE
259326 dbname = %s
327+ AND time > now() - interval '%s hours'
260328 AND data->>'queryid' IS NOT NULL
261329 AND data->>'query' IS NOT NULL
262330 ORDER BY data->>'queryid', time DESC
263331 """
264- cursor .execute (query , (db_name ,))
332+ cursor .execute (query , (db_name , max_age_hours ))
265333 else :
266334 query = """
267335 SELECT DISTINCT ON (data->>'queryid')
268336 data->>'queryid' as queryid,
269337 data->>'query' as query
270338 FROM public.pgss_queryid_queries
271339 WHERE
272- data->>'queryid' IS NOT NULL
340+ time > now() - interval '%s hours'
341+ AND data->>'queryid' IS NOT NULL
273342 AND data->>'query' IS NOT NULL
274343 ORDER BY data->>'queryid', time DESC
275344 """
276- cursor .execute (query )
345+ cursor .execute (query , ( max_age_hours ,) )
277346
278347 for row in cursor :
279348 queryid = row ['queryid' ]
@@ -1284,7 +1353,28 @@ def get_query_info_metrics():
12841353 try :
12851354 conn = psycopg2 .connect (POSTGRES_SINK_URL )
12861355 with conn .cursor (cursor_factory = psycopg2 .extras .DictCursor ) as cursor :
1287- # Skip db_name filter if it's empty, "All", or contains special chars
1356+ # Retention cleanup: delete stale entries in batches to prevent
1357+ # unbounded table growth from high-churn queryid sources (e.g. PostgREST)
1358+ try :
1359+ cursor .execute ("""
1360+ DELETE FROM public.pgss_queryid_queries
1361+ WHERE ctid IN (
1362+ SELECT ctid FROM public.pgss_queryid_queries
1363+ WHERE time < now() - interval '%s hours'
1364+ LIMIT 10000
1365+ )
1366+ """ , (QUERYID_RETENTION_HOURS ,))
1367+ deleted = cursor .rowcount
1368+ conn .commit ()
1369+ if deleted > 0 :
1370+ logger .info (f"Retention cleanup: deleted { deleted } stale queryid entries" )
1371+ except Exception as e :
1372+ logger .warning (f"Retention cleanup failed: { e } " )
1373+ conn .rollback ()
1374+
1375+ # Only export queryids recently seen in pg_stat_statements.
1376+ # The dedup trigger refreshes the timestamp on each collection,
1377+ # so active queryids have time within the last few minutes.
12881378 use_db_filter = db_name and db_name .lower () not in ('all' , '' ) and not db_name .startswith ('$' )
12891379 if use_db_filter :
12901380 query = """
@@ -1294,23 +1384,25 @@ def get_query_info_metrics():
12941384 FROM public.pgss_queryid_queries
12951385 WHERE
12961386 dbname = %s
1387+ AND time > now() - interval '%s minutes'
12971388 AND data->>'queryid' IS NOT NULL
12981389 AND data->>'query' IS NOT NULL
12991390 ORDER BY data->>'queryid', time DESC
13001391 """
1301- cursor .execute (query , (db_name ,))
1392+ cursor .execute (query , (db_name , QUERYID_ACTIVE_MINUTES ))
13021393 else :
13031394 query = """
13041395 SELECT DISTINCT ON (data->>'queryid')
13051396 data->>'queryid' as queryid,
13061397 data->>'query' as query
13071398 FROM public.pgss_queryid_queries
13081399 WHERE
1309- data->>'queryid' IS NOT NULL
1400+ time > now() - interval '%s minutes'
1401+ AND data->>'queryid' IS NOT NULL
13101402 AND data->>'query' IS NOT NULL
13111403 ORDER BY data->>'queryid', time DESC
13121404 """
1313- cursor .execute (query )
1405+ cursor .execute (query , ( QUERYID_ACTIVE_MINUTES ,) )
13141406
13151407 for row in cursor :
13161408 queryid = row ['queryid' ]
@@ -1355,6 +1447,8 @@ def get_query_info_metrics():
13551447 'full' : full ,
13561448 'queryid_only' : queryid_only ,
13571449 }
1450+
1451+ logger .info (f"Exported { len (query_data )} active queryids for metrics" )
13581452 except Exception as e :
13591453 logger .warning (f"Failed to fetch query texts from sink database: { e } " )
13601454 finally :
0 commit comments