diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0709831..b7ff50d 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "posthog", "description": "Access PostHog analytics, feature flags, experiments, error tracking, and insights directly from Claude Code. Optionally capture Claude Code sessions to PostHog LLM Analytics.", - "version": "1.1.25", + "version": "1.1.26", "author": { "name": "PostHog", "email": "hey@posthog.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b5cbd37..f36e833 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "posthog", - "version": "1.0.23", + "version": "1.0.24", "description": "Access PostHog analytics, feature flags, experiments, error tracking, and insights directly from Codex", "author": { "name": "PostHog", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 6ad1f40..8695ded 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "posthog", "displayName": "PostHog", - "version": "1.1.20", + "version": "1.1.21", "description": "Access PostHog analytics, feature flags, experiments, error tracking, and insights directly from Cursor", "author": { "name": "PostHog", diff --git a/gemini-extension.json b/gemini-extension.json index 481a62b..9068f1d 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "posthog", - "version": "1.0.22", + "version": "1.0.23", "description": "Access PostHog analytics, feature flags, experiments, error tracking, and insights directly from Gemini CLI", "mcpServers": { "posthog": { diff --git a/skills/.sync-manifest b/skills/.sync-manifest index 9ce9a6b..20ead84 100644 --- a/skills/.sync-manifest +++ b/skills/.sync-manifest @@ -21,6 +21,7 @@ exploring-llm-costs exploring-llm-evaluations exploring-llm-traces feature-usage-feed +finding-deleted-feature-flags finding-experiments finding-replay-for-issue formatting-insight-axes diff --git a/skills/finding-deleted-feature-flags/SKILL.md b/skills/finding-deleted-feature-flags/SKILL.md new file mode 100644 index 0000000..5bf0ef0 --- /dev/null +++ b/skills/finding-deleted-feature-flags/SKILL.md @@ -0,0 +1,121 @@ +--- +name: finding-deleted-feature-flags +description: 'Find feature flags that were soft-deleted in the active project within a recent time window. Use when the user asks "what flags were deleted in the last N days", "show me recently deleted feature flags", "who deleted flag X", "audit recent flag deletions", or anything similar. Handles the non-obvious gotcha that system.feature_flags exposes the deleted boolean but does not expose a deletion timestamp — the actual deleted-at time lives in the per-flag activity log and must be cross-referenced.' +--- + +# Finding recently deleted feature flags + +This skill produces a list of feature flags that were soft-deleted in the active project within a user-specified time window, along with who deleted each one and when. + +## When to use this skill + +- The user asks "what flags got deleted last week / in the last N days?" +- The user wants an audit of recent flag deletions (who, when, what was removed) +- The user wants to find when a specific flag was deleted, or by whom +- Any "recently deleted feature flags" framing + +Don't use this for **active** stale-flag cleanup — that's `cleaning-up-stale-feature-flags`. This skill is for flags that have already been removed. + +## The gotcha that makes this non-trivial + +`system.feature_flags` exposes `deleted` as a boolean but does **not** expose `deleted_at`, `updated_at`, or `last_modified_at`. There's no way to filter soft-deleted flags by deletion time in a single SQL query — trying to use those columns will return `Unable to resolve field`. + +The actual deletion timestamp lives in the per-flag activity log, reachable only via `posthog:feature-flags-activity-retrieve` (one call per flag id). There is no bulk activity endpoint. + +So the workflow is two-stage: SQL to enumerate candidates, then parallel activity-log lookups to find each deletion event. + +## Workflow + +### 1. Clarify the window if ambiguous + +"Last week" is ambiguous — it can mean rolling 7 days from now, or the previous calendar week (Mon–Sun). If the user wasn't explicit, ask, or surface both interpretations in the final report. + +Always compute the cutoff in UTC and keep the user's local interpretation in your head separately. + +### 2. Enumerate soft-deleted flags via SQL + +Query `system.feature_flags` for `deleted = true` in the active project, ordered by `created_at DESC`: + +```sql +SELECT id, key, created_at +FROM system.feature_flags +WHERE team_id = AND deleted = true +ORDER BY created_at DESC +LIMIT 100 +``` + +Order by `created_at DESC` because deletions empirically cluster near creation — most flags get deleted within a few days of being created — so walking the most-recently-created candidates first finds recent deletions fastest. **But** this is a heuristic, not a guarantee: an older flag deleted recently won't be at the top of this list. Be explicit about that limitation when you report. + +`team_id` defaults to the active project, but include it explicitly for clarity. + +### 3. Fan out activity-log lookups in parallel + +For each candidate id, call `posthog:feature-flags-activity-retrieve` with `limit: 5, page: 1`. **Issue all calls in one message so they run concurrently** — sequential calls are dramatically slower. + +```text +call feature-flags-activity-retrieve {"id": , "limit": 5, "page": 1} +``` + +Reasonable batch sizes: + +- "last 7 days" → top 20–25 candidates +- "last 30 days" → top 50 +- "last 90 days" → walk the full ~100 + +If you sample fewer than the full set, say so in the report and offer to walk the rest as a follow-up. + +### 4. Extract the deletion event from each response + +In each response, find the entry where `activity == "deleted"`. That entry's `created_at` is the actual deletion time, and `user.email` / `user.first_name` identify the deleter. + +The deletion event's `detail.changes` array typically contains: + +- `{field: "deleted", before: false, after: true}` — the actual delete +- `{field: "key", before: "", after: ":deleted:"}` — Django renames the key on delete to free up the unique constraint +- `{field: "name", ...}` — the name sometimes gets reset + +For most flags there's exactly one delete event. If a flag has been deleted-and-restored multiple times, take the most recent `activity: deleted` event within the window. + +### 5. Filter and report + +Filter the collected deletion events to those whose `created_at` falls inside the requested window. Present as a table: + +| Flag ID | Key | Deleted at (UTC) | Deleted by | + +State your methodology in the report (how many candidates you walked vs. how many soft-deleted flags exist total), so the user knows what was and wasn't checked. + +## Watch-outs + +- **Borderline cases**: if a deletion is within ~1 hour of the window cutoff, surface it as borderline rather than silently dropping it. +- **Don't trust `created_at` as a proxy for deletion time**: a flag created in 2024 can still have been deleted last week. The activity log is the only authority. +- **Renamed keys are normal**: a flag with key `foo:deleted:12345` was the flag originally keyed `foo`. The original key/name appears in the delete event's `detail.changes` array — surface that to the user, not the renamed form. +- **Walking all candidates is possible but slow**: ~100 parallel activity-log calls is doable. Offer it as a follow-up rather than the default for short windows. + +## Example interaction + +User: "what flags got deleted in the last week?" + +1. Clarify if needed, or note both interpretations: "rolling 7 days ending now (UTC), in the active project" +2. Run the SQL enumeration to get up to 100 soft-deleted candidates ordered by `created_at DESC` +3. Fan out activity-log lookups in parallel across the top ~25 candidates +4. Extract `activity: deleted` entries; filter to those whose `created_at >= now - 7 days` +5. Report: + + ```text + Found 2 feature flags deleted in the last 7 days (rolling, ending 2026-05-22 19:04 UTC): + + | Flag ID | Key | Deleted at (UTC) | Deleted by | + |---------|-------------------------------------------|----------------------|-------------| + | 687432 | high_frequency_alerts | 2026-05-22 17:23 | Matt P. | + | 676665 | tasks-sendblue-prewarmed-sandbox-pool | 2026-05-15 13:45 | Alessandro | + + Methodology: walked the activity log for the 25 most-recently-created soft-deleted + flags. Team 2 has ~100 soft-deleted flags total; the remaining ~75 were created + before mid-March 2026 and were not checked. Want me to walk the rest? + ``` + +## Related tools + +- `posthog:execute-sql`: Used in step 2 to enumerate soft-deleted candidates against `system.feature_flags` +- `posthog:feature-flags-activity-retrieve`: Used in step 3 to find the actual deletion event for each candidate +- `posthog:feature-flag-get-definition`: Useful if the user then wants to inspect what the deleted flag looked like diff --git a/skills/querying-posthog-data/references/example-error-tracking.md b/skills/querying-posthog-data/references/example-error-tracking.md index ff6a7ad..85d6e94 100644 --- a/skills/querying-posthog-data/references/example-error-tracking.md +++ b/skills/querying-posthog-data/references/example-error-tracking.md @@ -10,13 +10,13 @@ SELECT count(DISTINCT uuid) AS occurrences, count(DISTINCT nullIf($session_id, '')) AS sessions, count(DISTINCT coalesce(nullIf(toString(person_id), '00000000-0000-0000-0000-000000000000'), distinct_id)) AS users, - sumForEach(arrayMap(bin -> if(and(greater(timestamp, bin), lessOrEquals(dateDiff('seconds', bin, timestamp), divide(dateDiff('seconds', toDateTime(toDateTime('2026-05-20 20:38:01.817667')), toDateTime(toDateTime('2026-05-21 20:38:01.818191'))), 20))), 1, 0), arrayMap(i -> dateAdd(toDateTime(toDateTime('2026-05-20 20:38:01.817667')), toIntervalSecond(multiply(i, divide(dateDiff('seconds', toDateTime(toDateTime('2026-05-20 20:38:01.817667')), toDateTime(toDateTime('2026-05-21 20:38:01.818191'))), 20)))), range(0, 20)))) AS volumeRange, + sumForEach(arrayMap(bin -> if(and(greater(timestamp, bin), lessOrEquals(dateDiff('seconds', bin, timestamp), divide(dateDiff('seconds', toDateTime(toDateTime('2026-05-21 20:25:41.236619')), toDateTime(toDateTime('2026-05-22 20:25:41.237155'))), 20))), 1, 0), arrayMap(i -> dateAdd(toDateTime(toDateTime('2026-05-21 20:25:41.236619')), toIntervalSecond(multiply(i, divide(dateDiff('seconds', toDateTime(toDateTime('2026-05-21 20:25:41.236619')), toDateTime(toDateTime('2026-05-22 20:25:41.237155'))), 20)))), range(0, 20)))) AS volumeRange, argMin(tuple(uuid, distinct_id, timestamp, properties), timestamp) AS first_event, argMax(properties.$lib, timestamp) AS library FROM events AS e WHERE - and(equals(event, '$exception'), isNotNull(e.issue_id), equals(properties.tag, 'max_ai'), greaterOrEquals(timestamp, toDateTime(toDateTime('2026-05-20 20:38:01.817667'))), lessOrEquals(timestamp, toDateTime(toDateTime('2026-05-21 20:38:01.818191'))), or(greater(position(lower(properties.$exception_types), lower('constant')), 0), greater(position(lower(properties.$exception_values), lower('constant')), 0), greater(position(lower(properties.$exception_sources), lower('constant')), 0), greater(position(lower(properties.$exception_functions), lower('constant')), 0), greater(position(lower(properties.email), lower('constant')), 0), greater(position(lower(person.properties.email), lower('constant')), 0))) + and(equals(event, '$exception'), isNotNull(e.issue_id), equals(properties.tag, 'max_ai'), greaterOrEquals(timestamp, toDateTime(toDateTime('2026-05-21 20:25:41.236619'))), lessOrEquals(timestamp, toDateTime(toDateTime('2026-05-22 20:25:41.237155'))), or(greater(position(lower(properties.$exception_types), lower('constant')), 0), greater(position(lower(properties.$exception_values), lower('constant')), 0), greater(position(lower(properties.$exception_sources), lower('constant')), 0), greater(position(lower(properties.$exception_functions), lower('constant')), 0), greater(position(lower(properties.email), lower('constant')), 0), greater(position(lower(person.properties.email), lower('constant')), 0))) GROUP BY id ORDER BY diff --git a/skills/querying-posthog-data/references/example-logs.md b/skills/querying-posthog-data/references/example-logs.md index ddefdd4..670cb62 100644 --- a/skills/querying-posthog-data/references/example-logs.md +++ b/skills/querying-posthog-data/references/example-logs.md @@ -23,7 +23,7 @@ SELECT FROM logs WHERE - and(and(greaterOrEquals(toStartOfDay(time_bucket), toStartOfDay(assumeNotNull(toDateTime('2025-12-09 00:00:00')))), lessOrEquals(toStartOfDay(time_bucket), toStartOfDay(assumeNotNull(toDateTime('2025-12-10 00:00:00'))))), 1, greaterOrEquals(timestamp, toDateTime('2026-05-20 20:38:04.043120')), indexHint(like(lower(body), '%timeout%')), ilike(toString(body), '%timeout%'), in(severity_text, tuple('warn', 'error', 'fatal'))) + and(and(greaterOrEquals(toStartOfDay(time_bucket), toStartOfDay(assumeNotNull(toDateTime('2025-12-09 00:00:00')))), lessOrEquals(toStartOfDay(time_bucket), toStartOfDay(assumeNotNull(toDateTime('2025-12-10 00:00:00'))))), 1, greaterOrEquals(timestamp, toDateTime('2026-05-21 20:25:43.656852')), indexHint(like(lower(body), '%timeout%')), ilike(toString(body), '%timeout%'), in(severity_text, tuple('warn', 'error', 'fatal'))) ORDER BY timestamp DESC, uuid DESC diff --git a/skills/querying-posthog-data/references/example-session-replay.md b/skills/querying-posthog-data/references/example-session-replay.md index 684fbdb..7812b36 100644 --- a/skills/querying-posthog-data/references/example-session-replay.md +++ b/skills/querying-posthog-data/references/example-session-replay.md @@ -19,17 +19,17 @@ SELECT sum(s.console_error_count) AS console_error_count, max(s.retention_period_days) AS retention_period_days, plus(dateTrunc('DAY', start_time), toIntervalDay(coalesce(retention_period_days, 30))) AS expiry_time, - date_diff('DAY', toDateTime('2026-05-21 20:38:04.992159'), expiry_time) AS recording_ttl, - greaterOrEquals(max(s._timestamp), toDateTime('2026-05-21 20:33:04.991394')) AS ongoing, + date_diff('DAY', toDateTime('2026-05-22 20:25:44.656339'), expiry_time) AS recording_ttl, + greaterOrEquals(max(s._timestamp), toDateTime('2026-05-22 20:20:44.655475')) AS ongoing, round(multiply(divide(plus(plus(plus(divide(sum(s.active_milliseconds), 1000), sum(s.click_count)), sum(s.keypress_count)), sum(s.console_error_count)), plus(plus(plus(plus(sum(s.mouse_activity_count), dateDiff('SECOND', start_time, end_time)), sum(s.console_error_count)), sum(s.console_log_count)), sum(s.console_warn_count))), 100), 2) AS activity_score FROM raw_session_replay_events AS s WHERE - and(greaterOrEquals(s.min_first_timestamp, toDateTime('2026-05-18 00:00:00.000000')), lessOrEquals(s.min_first_timestamp, toDateTime('2026-05-21 20:38:04.991556'))) + and(greaterOrEquals(s.min_first_timestamp, toDateTime('2026-05-19 00:00:00.000000')), lessOrEquals(s.min_first_timestamp, toDateTime('2026-05-22 20:25:44.655652'))) GROUP BY session_id HAVING - and(greaterOrEquals(expiry_time, toDateTime('2026-05-21 20:38:04.992052')), equals(max(s.is_deleted), 0), greater(active_seconds, 5.0)) + and(greaterOrEquals(expiry_time, toDateTime('2026-05-22 20:25:44.656197')), equals(max(s.is_deleted), 0), greater(active_seconds, 5.0)) ORDER BY start_time DESC, session_id DESC diff --git a/skills/querying-posthog-data/references/example-sessions.md b/skills/querying-posthog-data/references/example-sessions.md index 6486e8a..a57eac7 100644 --- a/skills/querying-posthog-data/references/example-sessions.md +++ b/skills/querying-posthog-data/references/example-sessions.md @@ -13,7 +13,7 @@ SELECT FROM sessions WHERE - and(less($start_timestamp, toDateTime('2026-05-21 20:38:10.341356')), greater($start_timestamp, toDateTime('2026-05-20 20:38:05.341903'))) + and(less($start_timestamp, toDateTime('2026-05-22 20:25:50.007785')), greater($start_timestamp, toDateTime('2026-05-21 20:25:45.008598'))) ORDER BY $start_timestamp DESC LIMIT 50000 diff --git a/skills/querying-posthog-data/references/example-trends-breakdowns.md b/skills/querying-posthog-data/references/example-trends-breakdowns.md index 3981389..6441fb6 100644 --- a/skills/querying-posthog-data/references/example-trends-breakdowns.md +++ b/skills/querying-posthog-data/references/example-trends-breakdowns.md @@ -17,7 +17,7 @@ FROM count() AS total, toStartOfDay(timestamp) AS day_start, ifNull(nullIf(left(toString(properties.$browser), 400), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value_1, - properties.$browser_version AS breakdown_value_2 + toFloat(properties.$browser_version) AS breakdown_value_2 FROM events AS e WHERE @@ -35,7 +35,7 @@ FROM count() AS total, toStartOfDay(timestamp) AS day_start, ifNull(nullIf(left(toString(properties.$browser), 400), ''), '$$_posthog_breakdown_null_$$') AS breakdown_value_1, - properties.$browser_version AS breakdown_value_2, + toFloat(properties.$browser_version) AS breakdown_value_2, (SELECT [max(breakdown_value_2)] FROM @@ -44,9 +44,7 @@ FROM [min(breakdown_value_2)] FROM min_max) AS min_nums, - arrayMap((max_num, min_num) -> minus(max_num, min_num), arrayZip(max_nums, min_nums)) AS diff, - [10] AS bins, - arrayMap(i -> arrayMap(x -> [plus(multiply(divide(diff[i], bins[i]), x), min_nums[i]), plus(plus(multiply(divide(diff[i], bins[i]), plus(x, 1)), min_nums[i]), if(equals(plus(x, 1), bins[i]), 0.01, 0))], range(bins[i])), range(1, 2)) AS buckets + arrayMap((max_num, min_num, bin_count) -> arrayMap(x -> [plus(multiply(divide(minus(max_num, min_num), bin_count), x), min_num), plus(plus(multiply(divide(minus(max_num, min_num), bin_count), plus(x, 1)), min_num), if(equals(plus(x, 1), bin_count), 0.01, 0))], range(bin_count)), max_nums, min_nums, [10]) AS buckets FROM events AS e WHERE diff --git a/skills/querying-posthog-data/references/example-web-traffic-by-device-type.md b/skills/querying-posthog-data/references/example-web-traffic-by-device-type.md index 99d97a8..3671f13 100644 --- a/skills/querying-posthog-data/references/example-web-traffic-by-device-type.md +++ b/skills/querying-posthog-data/references/example-web-traffic-by-device-type.md @@ -23,8 +23,6 @@ FROM breakdown_value) GROUP BY `context.columns.breakdown_value` -HAVING - notEquals(`context.columns.breakdown_value`, NULL) ORDER BY `context.columns.visitors` DESC, `context.columns.views` DESC,