From c5385a123b5ed846670a38db8570faae677e4900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ledwo=C5=84?= Date: Fri, 24 Jul 2026 01:39:13 +0200 Subject: [PATCH 1/3] chore(codegen): expose logs sampling rules operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logs_sampling_rules list/create/retrieve/partial_update/destroy to the openapi-filter allowlist and regenerate src/generated/api.d.ts. The reorder endpoint is intentionally omitted — ordering is controlled via the settable, PATCH-updatable `priority` field. Co-Authored-By: Claude Fable 5 --- openapi-filter.yaml | 6 + src/generated/api.d.ts | 268 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) diff --git a/openapi-filter.yaml b/openapi-filter.yaml index 91d2650..0f118fd 100644 --- a/openapi-filter.yaml +++ b/openapi-filter.yaml @@ -86,6 +86,12 @@ inverseOperationIds: - actions_partial_update - actions_destroy + - logs_sampling_rules_list + - logs_sampling_rules_create + - logs_sampling_rules_retrieve + - logs_sampling_rules_partial_update + - logs_sampling_rules_destroy + unusedComponents: - schemas - parameters diff --git a/src/generated/api.d.ts b/src/generated/api.d.ts index fd88d9c..ddae365 100644 --- a/src/generated/api.d.ts +++ b/src/generated/api.d.ts @@ -616,6 +616,38 @@ export interface paths { patch: operations["insights_partial_update"]; trace?: never; }; + "/api/projects/{project_id}/logs/sampling_rules/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["logs_sampling_rules_list"]; + put?: never; + post: operations["logs_sampling_rules_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project_id}/logs/sampling_rules/{id}/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["logs_sampling_rules_retrieve"]; + put?: never; + post?: never; + delete: operations["logs_sampling_rules_destroy"]; + options?: never; + head?: never; + patch: operations["logs_sampling_rules_partial_update"]; + trace?: never; + }; "/api/projects/{project_id}/schema_property_groups/": { parameters: { query?: never; @@ -9351,6 +9383,47 @@ export interface components { * @enum {string} */ LogPropertyFilterType: "log" | "log_attribute" | "log_resource_attribute"; + LogsSamplingRule: { + /** + * Format: uuid + * @description Unique identifier for this sampling rule. + */ + readonly id: string; + /** @description User-visible label for this rule. */ + name: string; + /** + * @description When false, the rule is ignored by ingestion and listing UIs that show active rules only. + * @default false + */ + enabled: boolean; + /** @description Lower numbers are evaluated first; the first matching rule wins. Omit to append after existing rules. */ + priority?: number | null; + /** + * @description Rule kind: severity_sampling, path_drop, or rate_limit (caps matching log volume at ingestion). + * + * * `severity_sampling` - Severity-based reduction + * * `path_drop` - Path exclusion + * * `rate_limit` - Rate limit + */ + rule_type: components["schemas"]["RuleTypeEnum"]; + /** @description Optional legacy service-name scope; new rules use `config.filter_group` for matching instead. */ + scope_service?: string | null; + /** @description Optional regex matched against a path-like log attribute when present. */ + scope_path_pattern?: string | null; + /** @description Optional list of predicates over string attributes, e.g. [{"key":"http.route","op":"eq","value":"/api"}]. */ + scope_attribute_filters?: { + [key: string]: unknown; + }[]; + /** @description Type-specific JSON. For path_drop: object with optional `filter_group` (PropertyGroupFilter shape — AND/OR tree of property predicates evaluated per record) and/or legacy `patterns` (list of regex strings) + `match_attribute_key` (string). When both are present a record is dropped if EITHER matches. Filter group example: `{"type":"AND","values":[{"type":"AND","values":[{"key":"service.name","operator":"exact","value":"api"}]}]}`. Every group in `filter_group` must contain at least one filter — empty groups never match, so the rule would never apply. For severity_sampling: object with `actions` per severity level and optional `always_keep`. For rate_limit: object with EITHER `logs_per_second` (integer 1–1000000, optional `burst_logs` integer ≥ logs_per_second, max 10000000) OR `kb_per_second` (integer 1–1000000 = 1 GB/s, optional `burst_kb` integer ≥ kb_per_second, max 10000000) — not both. Plus optional `filter_group` to narrow which logs the cap applies to. KB-mode charges each log its own uncompressed byte size, matching how billing measures ingested bytes. */ + config: unknown; + /** @description Incremented on each update for worker cache coherency. */ + readonly version: number; + readonly created_by: number; + /** Format: date-time */ + readonly created_at: string; + /** Format: date-time */ + readonly updated_at: string | null; + }; /** * ManualMetricType * @enum {string} @@ -10287,6 +10360,21 @@ export interface components { previous?: string | null; results: components["schemas"]["Insight"][]; }; + PaginatedLogsSamplingRuleList: { + /** @example 123 */ + count: number; + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=400&limit=100 + */ + next?: string | null; + /** + * Format: uri + * @example http://api.example.org/accounts/?offset=200&limit=100 + */ + previous?: string | null; + results: components["schemas"]["LogsSamplingRule"][]; + }; PaginatedSchemaPropertyGroupList: { /** @example 123 */ count: number; @@ -10692,6 +10780,47 @@ export interface components { /** @description How this row matched the `search` query parameter: `exact` (the term is a case-insensitive substring of a searched field) or `similar` (a fuzzy trigram match, returned only when no exact match exists). Null when the list is not filtered by `search`. */ readonly search_match_type?: components["schemas"]["SearchMatchTypeEnum"] | components["schemas"]["NullEnum"]; }; + PatchedLogsSamplingRule: { + /** + * Format: uuid + * @description Unique identifier for this sampling rule. + */ + readonly id?: string; + /** @description User-visible label for this rule. */ + name?: string; + /** + * @description When false, the rule is ignored by ingestion and listing UIs that show active rules only. + * @default false + */ + enabled: boolean; + /** @description Lower numbers are evaluated first; the first matching rule wins. Omit to append after existing rules. */ + priority?: number | null; + /** + * @description Rule kind: severity_sampling, path_drop, or rate_limit (caps matching log volume at ingestion). + * + * * `severity_sampling` - Severity-based reduction + * * `path_drop` - Path exclusion + * * `rate_limit` - Rate limit + */ + rule_type?: components["schemas"]["RuleTypeEnum"]; + /** @description Optional legacy service-name scope; new rules use `config.filter_group` for matching instead. */ + scope_service?: string | null; + /** @description Optional regex matched against a path-like log attribute when present. */ + scope_path_pattern?: string | null; + /** @description Optional list of predicates over string attributes, e.g. [{"key":"http.route","op":"eq","value":"/api"}]. */ + scope_attribute_filters?: { + [key: string]: unknown; + }[]; + /** @description Type-specific JSON. For path_drop: object with optional `filter_group` (PropertyGroupFilter shape — AND/OR tree of property predicates evaluated per record) and/or legacy `patterns` (list of regex strings) + `match_attribute_key` (string). When both are present a record is dropped if EITHER matches. Filter group example: `{"type":"AND","values":[{"type":"AND","values":[{"key":"service.name","operator":"exact","value":"api"}]}]}`. Every group in `filter_group` must contain at least one filter — empty groups never match, so the rule would never apply. For severity_sampling: object with `actions` per severity level and optional `always_keep`. For rate_limit: object with EITHER `logs_per_second` (integer 1–1000000, optional `burst_logs` integer ≥ logs_per_second, max 10000000) OR `kb_per_second` (integer 1–1000000 = 1 GB/s, optional `burst_kb` integer ≥ kb_per_second, max 10000000) — not both. Plus optional `filter_group` to narrow which logs the cap applies to. KB-mode charges each log its own uncompressed byte size, matching how billing measures ingested bytes. */ + config?: unknown; + /** @description Incremented on each update for worker cache coherency. */ + readonly version?: number; + readonly created_by?: number; + /** Format: date-time */ + readonly created_at?: string; + /** Format: date-time */ + readonly updated_at?: string | null; + }; /** * @description OpenAPI-only PATCH body for dashboards (agents/MCP). * @@ -14665,6 +14794,13 @@ export interface components { * @enum {string} */ RoleAtOrganizationEnum: "engineering" | "data" | "product" | "founder" | "leadership" | "marketing" | "sales" | "other"; + /** + * @description * `severity_sampling` - Severity-based reduction + * * `path_drop` - Path exclusion + * * `rate_limit` - Rate limit + * @enum {string} + */ + RuleTypeEnum: "severity_sampling" | "path_drop" | "rate_limit"; /** SamplingRate */ SamplingRate: { /** @@ -20502,6 +20638,138 @@ export interface operations { }; }; }; + logs_sampling_rules_list: { + parameters: { + query?: { + /** @description Number of results to return per page. */ + limit?: number; + /** @description The initial index from which to return the results. */ + offset?: number; + }; + header?: never; + path: { + /** @description Project ID of the project you're trying to access. To find the ID of the project, make a call to /api/projects/. */ + project_id: components["parameters"]["ProjectIdPath"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedLogsSamplingRuleList"]; + }; + }; + }; + }; + logs_sampling_rules_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ID of the project you're trying to access. To find the ID of the project, make a call to /api/projects/. */ + project_id: components["parameters"]["ProjectIdPath"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LogsSamplingRule"]; + "application/x-www-form-urlencoded": components["schemas"]["LogsSamplingRule"]; + "multipart/form-data": components["schemas"]["LogsSamplingRule"]; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogsSamplingRule"]; + }; + }; + }; + }; + logs_sampling_rules_retrieve: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A UUID string identifying this logs exclusion rule. */ + id: string; + /** @description Project ID of the project you're trying to access. To find the ID of the project, make a call to /api/projects/. */ + project_id: components["parameters"]["ProjectIdPath"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogsSamplingRule"]; + }; + }; + }; + }; + logs_sampling_rules_destroy: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A UUID string identifying this logs exclusion rule. */ + id: string; + /** @description Project ID of the project you're trying to access. To find the ID of the project, make a call to /api/projects/. */ + project_id: components["parameters"]["ProjectIdPath"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No response body */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + logs_sampling_rules_partial_update: { + parameters: { + query?: never; + header?: never; + path: { + /** @description A UUID string identifying this logs exclusion rule. */ + id: string; + /** @description Project ID of the project you're trying to access. To find the ID of the project, make a call to /api/projects/. */ + project_id: components["parameters"]["ProjectIdPath"]; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["PatchedLogsSamplingRule"]; + "application/x-www-form-urlencoded": components["schemas"]["PatchedLogsSamplingRule"]; + "multipart/form-data": components["schemas"]["PatchedLogsSamplingRule"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LogsSamplingRule"]; + }; + }; + }; + }; schema_property_groups_list: { parameters: { query?: { From 1f1c370ee7fccd7912b3bab8d9c907bfa52259c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ledwo=C5=84?= Date: Fri, 24 Jul 2026 09:46:13 +0200 Subject: [PATCH 2/3] feat(logs-sampling-rule): SDK factory + pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manage logs sampling rules (projects/{id}/logs/sampling_rules) as code — the campaign's first order-sensitive collection. Identity rides a trailing HTML-comment marker in `name` (subscriptions pattern; maxLength 255, guarded). Ordering: `priority` (lower first, first-match-wins) is a settable AND PATCH-updatable field that round-trips, so order is handled as a normal hashed field — a priority change is a real, pushable diff. No reorder-endpoint machinery or generic pipeline extension needed; the order-awareness is the hash projection including `priority`. `config` is a type-specific passthrough bag; `enabled`/`rule_type`/scope fields round-trip; `description`/`tags` are dropped by the create serializer (live-verified). Real PATCH update + real DELETE (204). Includes pull codegen, unit tests (incl. the priority-change diff), two examples with explicit priorities, README scope (logs:read/write), resources.md. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- docs/resources.md | 14 +- .../logs-sampling-rules/cap_debug_volume.ts | 25 ++ .../logs-sampling-rules/drop_healthchecks.ts | 23 ++ src/index.ts | 3 + src/resources/index.ts | 3 + src/resources/logs-sampling-rule/client.ts | 120 ++++++++++ src/resources/logs-sampling-rule/codegen.ts | 75 ++++++ src/resources/logs-sampling-rule/index.ts | 65 ++++++ .../logs-sampling-rule/pipeline.test.ts | 131 +++++++++++ src/resources/logs-sampling-rule/pipeline.ts | 216 ++++++++++++++++++ src/resources/logs-sampling-rule/sdk.ts | 46 ++++ src/resources/order.test.ts | 2 + 13 files changed, 724 insertions(+), 2 deletions(-) create mode 100644 examples/posthog/logs-sampling-rules/cap_debug_volume.ts create mode 100644 examples/posthog/logs-sampling-rules/drop_healthchecks.ts create mode 100644 src/resources/logs-sampling-rule/client.ts create mode 100644 src/resources/logs-sampling-rule/codegen.ts create mode 100644 src/resources/logs-sampling-rule/index.ts create mode 100644 src/resources/logs-sampling-rule/pipeline.test.ts create mode 100644 src/resources/logs-sampling-rule/pipeline.ts create mode 100644 src/resources/logs-sampling-rule/sdk.ts diff --git a/README.md b/README.md index fba4e1c..1bfb956 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > **Alpha — do not use in production.** > This is pre-MVP software (`0.1.0-alpha.0`). The CLI, the on-disk file format, the SDK surface, and the tag-based identity model are all subject to breaking changes without notice. Use it on throwaway projects or in a sandbox while we stabilize. -Infrastructure-as-code for PostHog. Define dashboards, insights, feature flags, actions, endpoints, event definitions, property groups, experiments, experiment holdouts, and experiment saved metrics in TypeScript, then sync them to a PostHog project with one command. +Infrastructure-as-code for PostHog. Define dashboards, insights, feature flags, actions, endpoints, event definitions, property groups, experiments, experiment holdouts, experiment saved metrics, and logs sampling rules in TypeScript, then sync them to a PostHog project with one command. ## Why @@ -51,6 +51,7 @@ export default dashboard({ - `event_definition:read`, `event_definition:write` (also covers property groups) - `experiment:read`, `experiment:write` (also covers experiment holdouts) - `experiment_saved_metric:read`, `experiment_saved_metric:write` + - `logs:read`, `logs:write` (logs sampling rules) Then add it (and your numeric project ID) to a `.env` (or `.envrc`) file: diff --git a/docs/resources.md b/docs/resources.md index a78a77c..bb094b9 100644 --- a/docs/resources.md +++ b/docs/resources.md @@ -80,6 +80,18 @@ Source of truth for the API column: registered viewsets in [`posthog/posthog/api | Spike detection config | ✅ `environments/{id}/error_tracking/spike_detection_config` | ❌ | | | Settings | ✅ `environments/{id}/error_tracking/settings` | ❌ | | +## Logs + +Rows added ahead of the full matrix refresh (#78). See the parity plan for the +Wave-2 logs verdicts. + +| Resource | PostHog API | posthog-definitions | Notes | +| ------------------- | -------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Logs sampling rules | ✅ `projects/{id}/logs/sampling_rules` | ✅ | Identity via `iac:logs-sampling-rules:` marker in `name`. **Order-sensitive** — `priority` (lower first, first-match-wins) is a settable, PATCH-updatable, hashed field. Real PATCH + real DELETE. | +| Logs views | ✅ `projects/{id}/logs/views` | ❌ | Buildable (shipped separately) — `name` marker. | +| Logs metric rules | ✅ `projects/{id}/logs/metric_rules` | ❌ | Gated behind org feature flag `logs-metric-rules`. | +| Logs alerts | ✅ `projects/{id}/logs/alerts` | ❌ | Deferred — `name`-only carrier is notification-facing; destinations are an unreadable imperative subresource. | + ## Project & org configuration | Resource | PostHog API | posthog-definitions | Notes | @@ -96,7 +108,7 @@ Source of truth for the API column: registered viewsets in [`posthog/posthog/api ## Summary -Currently shipped: **12 resource types** — Dashboards, Insights, Feature flags, Endpoints, Schema property groups, Event definitions, Experiments, Experiment holdouts, Experiment saved metrics, Project settings, Cohorts, and Actions. Event definitions and property groups together feed `createTypedPostHog`, which wraps any `posthog-js`-shaped client and type-checks `.capture(name, properties)` at compile time against the same specs synced via `apply`. Experiments are declarative across the full lifecycle (draft / running / paused / stopped) — apply drives the launch / pause / resume / end transitions to match. Project settings is the first singleton resource: declared as one block, field-level diff against the live row, declared-only PATCH. Cohorts run before feature flags in the apply order, leaving the door open for cohort-by-key references inside flag conditions. +Currently shipped: **13 resource types** — Dashboards, Insights, Feature flags, Endpoints, Schema property groups, Event definitions, Experiments, Experiment holdouts, Experiment saved metrics, Project settings, Cohorts, Actions, and Logs sampling rules. Event definitions and property groups together feed `createTypedPostHog`, which wraps any `posthog-js`-shaped client and type-checks `.capture(name, properties)` at compile time against the same specs synced via `apply`. Experiments are declarative across the full lifecycle (draft / running / paused / stopped) — apply drives the launch / pause / resume / end transitions to match. Project settings is the first singleton resource: declared as one block, field-level diff against the live row, declared-only PATCH. Cohorts run before feature flags in the apply order, leaving the door open for cohort-by-key references inside flag conditions. Reasonable IaC targets across the API surface: **~25–30** (cohorts, actions, surveys, annotations, alerts, hog functions/flows, error-tracking rules, warehouse queries, batch exports, …). diff --git a/examples/posthog/logs-sampling-rules/cap_debug_volume.ts b/examples/posthog/logs-sampling-rules/cap_debug_volume.ts new file mode 100644 index 0000000..35d1b81 --- /dev/null +++ b/examples/posthog/logs-sampling-rules/cap_debug_volume.ts @@ -0,0 +1,25 @@ +import { logsSamplingRule } from "../../../src/index.js"; + +// Priority 20 — runs after the path-drop above. Caps debug-level chatter at +// 500 logs/sec (with a small burst) so a runaway loop can't blow the ingestion +// bill, while errors and warnings sail through untouched. +export default logsSamplingRule({ + key: "cap_debug_volume", + name: "Rate-limit debug logs", + ruleType: "rate_limit", + priority: 20, + enabled: true, + config: { + logs_per_second: 500, + burst_logs: 2000, + filter_group: { + type: "AND", + values: [ + { + type: "AND", + values: [{ key: "severity_text", operator: "exact", value: "debug", type: "log_attribute" }], + }, + ], + }, + }, +}); diff --git a/examples/posthog/logs-sampling-rules/drop_healthchecks.ts b/examples/posthog/logs-sampling-rules/drop_healthchecks.ts new file mode 100644 index 0000000..7f8b1a2 --- /dev/null +++ b/examples/posthog/logs-sampling-rules/drop_healthchecks.ts @@ -0,0 +1,23 @@ +import { logsSamplingRule } from "../../../src/index.js"; + +// Priority 10 — evaluated first, so the noisiest thing (load-balancer health +// pings) gets dropped before any rate-limit rule wastes budget on it. Order is +// meaningful here: lower priority wins, so keep the drops ahead of the limits. +export default logsSamplingRule({ + key: "drop_healthchecks", + name: "Drop /healthz access logs", + ruleType: "path_drop", + priority: 10, + enabled: true, + config: { + filter_group: { + type: "AND", + values: [ + { + type: "AND", + values: [{ key: "http.route", operator: "exact", value: "/healthz", type: "log_attribute" }], + }, + ], + }, + }, +}); diff --git a/src/index.ts b/src/index.ts index 2cc03cc..3e0dc28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,5 +75,8 @@ export type { export { projectSettings } from "./resources/project-settings/index.js"; export type { ProjectSettings } from "./resources/project-settings/index.js"; +export { logsSamplingRule } from "./resources/logs-sampling-rule/index.js"; +export type { LogsSamplingRule, LogsSamplingRuleType } from "./resources/logs-sampling-rule/index.js"; + export { createTypedPostHog } from "./client/typed-posthog.js"; export type { TypedPostHog, CaptureCapableClient } from "./client/typed-posthog.js"; diff --git a/src/resources/index.ts b/src/resources/index.ts index bbd786c..5076e08 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -6,6 +6,7 @@ import { dashboardResource } from "./dashboard/index.js"; import { endpointResource } from "./endpoint/index.js"; import { featureFlagResource } from "./feature-flag/index.js"; import { insightResource } from "./insight/index.js"; +import { logsSamplingRuleResource } from "./logs-sampling-rule/index.js"; import { eventDefinitionResource } from "./event-definition/index.js"; import { experimentResource } from "./experiment/index.js"; import { experimentHoldoutResource } from "./experiment-holdout/index.js"; @@ -29,6 +30,7 @@ const REGISTRY: ReadonlyArray> = [ experimentSavedMetricResource as ResourceModule, featureFlagResource as ResourceModule, insightResource as ResourceModule, + logsSamplingRuleResource as ResourceModule, projectSettingsResource as ResourceModule, propertyGroupResource as ResourceModule, ]; @@ -42,6 +44,7 @@ const REGISTRY: ReadonlyArray> = [ export const RESOURCES: ReadonlyArray> = topoOrder(REGISTRY); export { insightResource } from "./insight/index.js"; +export { logsSamplingRuleResource } from "./logs-sampling-rule/index.js"; export { dashboardResource } from "./dashboard/index.js"; export { actionResource } from "./action/index.js"; export { cohortResource } from "./cohort/index.js"; diff --git a/src/resources/logs-sampling-rule/client.ts b/src/resources/logs-sampling-rule/client.ts new file mode 100644 index 0000000..a582b0b --- /dev/null +++ b/src/resources/logs-sampling-rule/client.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import type { ClientConfig } from "../../client/config.js"; +import type { components } from "../../generated/api.js"; +import { createApiClient, followPagination, type Paginated } from "../../client/typed.js"; + +const MANAGED_NAME_PREFIX = "`; + return { + id, + name: `${userName}\n\n${marker}`, + rule_type: "rate_limit", + config: { logs_per_second: 1000 }, + priority: 10, + enabled: false, + }; +} + +function desiredFor(specs: LogsSamplingRule[]): DesiredState { + const state: DesiredState = new Map(); + state.set( + "logs-sampling-rules", + specs.map((spec) => ({ path: "", spec })), + ); + return state; +} + +function currentFor(rows: ServerLogsSamplingRule[]): Map { + return new Map([["logs-sampling-rules", rows]]); +} + +describe("logs-sampling-rule pipeline", () => { + it("emits create when desired has no matching server row", () => { + const slice = diff(desiredFor([spec("r1")]), currentFor([])).get("logs-sampling-rules")!; + expect(slice.ops[0]!.kind).toBe("create"); + }); + + it("emits unchanged when server hash matches", () => { + const desired = spec("r1"); + const op = diff( + desiredFor([desired]), + currentFor([serverRow("id1", "r1", logsSamplingRuleHash(desired), "Rule r1")]), + ).get("logs-sampling-rules")!.ops[0]!; + expect(op.kind).toBe("unchanged"); + }); + + it("emits update when server hash differs", () => { + const op = diff( + desiredFor([spec("r1")]), + currentFor([serverRow("id1", "r1", "stale00000000")]), + ).get("logs-sampling-rules")!.ops[0]!; + expect(op.kind).toBe("update"); + }); + + it("hash changes when priority changes (order is part of identity)", () => { + expect(logsSamplingRuleHash(spec("r"))).not.toBe( + logsSamplingRuleHash(spec("r", { priority: 20 })), + ); + }); + + it("hash changes when config, rule_type, name, or enabled change", () => { + const base = spec("r"); + expect(logsSamplingRuleHash(base)).not.toBe(logsSamplingRuleHash(spec("r", { config: { logs_per_second: 5 } }))); + expect(logsSamplingRuleHash(base)).not.toBe(logsSamplingRuleHash(spec("r", { ruleType: "path_drop" }))); + expect(logsSamplingRuleHash(base)).not.toBe(logsSamplingRuleHash(spec("r", { name: "Other" }))); + expect(logsSamplingRuleHash(base)).not.toBe(logsSamplingRuleHash(spec("r", { enabled: true }))); + }); + + it("classifies a server-only managed rule as an orphan", () => { + const slice = diff(desiredFor([]), currentFor([serverRow("id9", "ghost", "any")])).get( + "logs-sampling-rules", + )!; + expect(slice.orphans.length).toBe(1); + }); + + it("safety invariant: ignores server rows without the identity marker", () => { + const handBuilt: ServerLogsSamplingRule = { + id: "hand", + name: "A sampling rule created in the UI", + rule_type: "rate_limit", + config: { logs_per_second: 1 }, + enabled: true, + }; + const slice = diff(desiredFor([]), currentFor([handBuilt])).get("logs-sampling-rules")!; + expect(slice.ops.length).toBe(0); + expect(slice.orphans.length).toBe(0); + }); +}); + +describe("logs-sampling-rule validation", () => { + const state: DesiredState = new Map(); + + it("requires a name (it carries the marker)", () => { + const issues = validateLogsSamplingRules([spec("r", { name: "" })], state); + expect(issues.some((m) => m.includes("name is required"))).toBeTruthy(); + }); + + it("rejects an unknown ruleType", () => { + const issues = validateLogsSamplingRules( + [spec("r", { ruleType: "nope" as LogsSamplingRule["ruleType"] })], + state, + ); + expect(issues.some((m) => m.includes("ruleType must be one of"))).toBeTruthy(); + }); + + it("rejects a negative priority", () => { + const issues = validateLogsSamplingRules([spec("r", { priority: -1 })], state); + expect(issues.some((m) => m.includes("non-negative integer"))).toBeTruthy(); + }); + + it("rejects a name that, with the marker, exceeds the 255-char cap", () => { + const issues = validateLogsSamplingRules([spec("r", { name: "A".repeat(255) })], state); + expect(issues.some((m) => m.includes("name is too long"))).toBeTruthy(); + }); + + it("accepts a valid rule", () => { + expect(validateLogsSamplingRules([spec("ok")], state)).toEqual([]); + }); +}); diff --git a/src/resources/logs-sampling-rule/pipeline.ts b/src/resources/logs-sampling-rule/pipeline.ts new file mode 100644 index 0000000..f587e08 --- /dev/null +++ b/src/resources/logs-sampling-rule/pipeline.ts @@ -0,0 +1,216 @@ +import type { ClientConfig } from "../../client/config.js"; +import { ApiError } from "../../client/typed.js"; +import { specHash } from "../../apply/hash.js"; +import { obj, scalar, displayJson, type DisplayValue } from "../../apply/display.js"; +import { SafetyViolationError } from "../../apply/errors.js"; +import { getResourceKind, type ApplyContext, type DesiredState, type ResourceOp } from "../types.js"; +import type { LogsSamplingRule, LogsSamplingRuleType } from "./sdk.js"; +import { + createLogsSamplingRule, + deleteLogsSamplingRule, + getLogsSamplingRule, + type LogsSamplingRuleCreate, + type ServerLogsSamplingRule, + updateLogsSamplingRule, +} from "./client.js"; + +export const LOGS_SAMPLING_RULE_IDENTITY_PREFIX = "iac:logs-sampling-rules:"; + +/** Server caps `name` at 255 chars; the name also carries the identity marker. */ +const NAME_MAX = 255; +const RULE_TYPES: LogsSamplingRuleType[] = ["severity_sampling", "path_drop", "rate_limit"]; + +const MARKER_REGEX = /\n*\s*$/; +const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +type ParsedMarker = { userName: string; key: string; hash: string }; + +function parseMarker(name: string | null | undefined): ParsedMarker | undefined { + if (!name) return undefined; + const match = name.match(MARKER_REGEX); + if (!match || match.index === undefined) return undefined; + return { + userName: name.slice(0, match.index).replace(/\s+$/, ""), + key: match[1]!, + hash: match[2]!, + }; +} + +export function withMarker(userName: string, key: string, hash: string): string { + const trailer = ``; + const trimmed = userName.replace(/\s+$/, ""); + return trimmed ? `${trimmed}\n\n${trailer}` : trailer; +} + +export function stripMarker(name: string | null | undefined): string | null { + if (!name) return null; + const parsed = parseMarker(name); + return parsed ? parsed.userName || null : name; +} + +export function logsSamplingRuleKeyFromServer(server: ServerLogsSamplingRule): string | undefined { + return parseMarker(server.name)?.key; +} + +export function logsSamplingRuleHashFromServer(server: ServerLogsSamplingRule): string | undefined { + return parseMarker(server.name)?.hash; +} + +function specForHash(spec: LogsSamplingRule): unknown { + return { + key: spec.key, + name: spec.name, + rule_type: spec.ruleType, + config: spec.config ?? null, + enabled: spec.enabled ?? false, + // Order is part of identity for this collection: a priority change is a + // real, pushable diff. `null` when the user leaves ordering to the server. + priority: spec.priority ?? null, + scope_service: spec.scopeService ?? null, + scope_path_pattern: spec.scopePathPattern ?? null, + scope_attribute_filters: spec.scopeAttributeFilters ?? null, + }; +} + +export function logsSamplingRuleHash(spec: LogsSamplingRule): string { + return specHash(specForHash(spec)); +} + +function buildPayload(spec: LogsSamplingRule, hash: string): LogsSamplingRuleCreate { + const payload: LogsSamplingRuleCreate = { + name: withMarker(spec.name, spec.key, hash), + rule_type: spec.ruleType, + config: spec.config, + enabled: spec.enabled ?? false, + }; + if (spec.priority !== undefined) payload.priority = spec.priority; + if (spec.scopeService !== undefined) payload.scope_service = spec.scopeService; + if (spec.scopePathPattern !== undefined) payload.scope_path_pattern = spec.scopePathPattern; + if (spec.scopeAttributeFilters !== undefined) + payload.scope_attribute_filters = spec.scopeAttributeFilters; + return payload; +} + +export function looksLikeLogsSamplingRule(value: unknown): value is LogsSamplingRule { + return getResourceKind(value) === "logs-sampling-rule"; +} + +export function validateLogsSamplingRules(specs: LogsSamplingRule[], _state: DesiredState): string[] { + const issues: string[] = []; + const seenKeys = new Set(); + + for (const spec of specs) { + if (!spec.key) { + issues.push("logsSamplingRule.key is required"); + continue; + } + if (!KEY_PATTERN.test(spec.key)) { + issues.push(`logsSamplingRule "${spec.key}" key must match ${KEY_PATTERN.source}`); + } + if (seenKeys.has(spec.key)) issues.push(`Duplicate logsSamplingRule key "${spec.key}"`); + seenKeys.add(spec.key); + + if (!spec.name || spec.name.trim() === "") { + issues.push(`logsSamplingRule "${spec.key}" name is required (it carries the identity marker)`); + } else { + const withMarkerLen = withMarker(spec.name, spec.key, logsSamplingRuleHash(spec)).length; + if (withMarkerLen > NAME_MAX) { + issues.push( + `logsSamplingRule "${spec.key}" name is too long: name + identity marker is ${withMarkerLen} chars but the server caps \`name\` at ${NAME_MAX}. Shorten the name (or key).`, + ); + } + } + + if (!spec.ruleType) { + issues.push(`logsSamplingRule "${spec.key}" ruleType is required`); + } else if (!RULE_TYPES.includes(spec.ruleType)) { + issues.push( + `logsSamplingRule "${spec.key}" ruleType must be one of ${RULE_TYPES.join(", ")}`, + ); + } + + if (spec.config === undefined || spec.config === null) { + issues.push(`logsSamplingRule "${spec.key}" config is required`); + } + + if (spec.priority !== undefined && (!Number.isInteger(spec.priority) || spec.priority < 0)) { + issues.push(`logsSamplingRule "${spec.key}" priority must be a non-negative integer`); + } + } + return issues; +} + +async function assertManaged( + config: ClientConfig, + id: string, + key: string, + options: { verbose?: boolean }, +): Promise { + const current = await getLogsSamplingRule(config, id, options); + if (logsSamplingRuleKeyFromServer(current) !== key) { + throw new SafetyViolationError("logs-sampling-rule", id, key); + } +} + +export async function runLogsSamplingRuleOp( + config: ClientConfig, + op: ResourceOp, + _ctx: ApplyContext, + options: { verbose?: boolean } = {}, +): Promise { + if (op.kind === "unchanged") return; + + const payload = buildPayload(op.spec, logsSamplingRuleHash(op.spec)); + + if (op.kind === "create") { + await createLogsSamplingRule(config, payload, options); + return; + } + + await assertManaged(config, op.server.id, op.spec.key, options); + await updateLogsSamplingRule(config, op.server.id, payload, options); +} + +export async function pruneLogsSamplingRule( + config: ClientConfig, + orphan: ServerLogsSamplingRule, + options: { verbose?: boolean } = {}, +): Promise { + const key = logsSamplingRuleKeyFromServer(orphan) ?? `id:${orphan.id}`; + try { + await assertManaged(config, orphan.id, key, options); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return false; + throw err; + } + await deleteLogsSamplingRule(config, orphan.id, options); + return true; +} + +export function displayLogsSamplingRule(spec: LogsSamplingRule): DisplayValue { + return obj([ + ["key", scalar(spec.key)], + ["name", scalar(spec.name)], + ["rule_type", scalar(spec.ruleType)], + ["priority", scalar(spec.priority ?? null)], + ["enabled", scalar(spec.enabled ?? false)], + ["config", displayJson(spec.config ?? null)], + ["scope_service", scalar(spec.scopeService ?? null)], + ["scope_path_pattern", scalar(spec.scopePathPattern ?? null)], + ["scope_attribute_filters", displayJson(spec.scopeAttributeFilters ?? null)], + ]); +} + +export function displayLogsSamplingRuleFromServer(server: ServerLogsSamplingRule): DisplayValue { + return obj([ + ["key", scalar(logsSamplingRuleKeyFromServer(server) ?? null)], + ["name", scalar(stripMarker(server.name))], + ["rule_type", scalar(server.rule_type ?? null)], + ["priority", scalar(server.priority ?? null)], + ["enabled", scalar(server.enabled ?? false)], + ["config", displayJson(server.config ?? null)], + ["scope_service", scalar(server.scope_service ?? null)], + ["scope_path_pattern", scalar(server.scope_path_pattern ?? null)], + ["scope_attribute_filters", displayJson(server.scope_attribute_filters ?? null)], + ]); +} diff --git a/src/resources/logs-sampling-rule/sdk.ts b/src/resources/logs-sampling-rule/sdk.ts new file mode 100644 index 0000000..190b29d --- /dev/null +++ b/src/resources/logs-sampling-rule/sdk.ts @@ -0,0 +1,46 @@ +import { markResourceKind } from "../types.js"; + +export type LogsSamplingRuleType = "severity_sampling" | "path_drop" | "rate_limit"; + +export type LogsSamplingRule = { + key: string; + /** + * Human-readable label shown in the sampling-rules settings list. Also + * carries the identity marker (a trailing HTML comment). Capped at 255 chars + * including the marker — a validation guard fails fast if exceeded. + */ + name: string; + /** Rule kind. Determines the shape of `config`. */ + ruleType: LogsSamplingRuleType; + /** + * Type-specific configuration, passed through verbatim. Shape depends on + * `ruleType`: + * - `rate_limit`: `{ logs_per_second }` or `{ kb_per_second }` (+ optional + * `burst_*`, `filter_group`). + * - `path_drop`: `{ filter_group }` and/or legacy `{ patterns, match_attribute_key }`. + * - `severity_sampling`: `{ actions, always_keep? }`. + */ + config: Record; + /** + * When false (the SDK default), ingestion ignores the rule. Author rules + * disabled and flip them on deliberately. + */ + enabled?: boolean; + /** + * Evaluation priority — **lower is evaluated first, and the first matching + * rule wins**. This collection is order-sensitive; set `priority` explicitly + * on every rule when order matters, and renumber to reorder. Omit to let the + * server append after existing rules (order then undefined relative to peers). + */ + priority?: number; + /** Optional legacy service-name scope; prefer `config.filter_group`. */ + scopeService?: string | null; + /** Optional regex matched against a path-like log attribute. */ + scopePathPattern?: string | null; + /** Optional predicates over string attributes, e.g. `[{ key, op, value }]`. */ + scopeAttributeFilters?: Record[]; +}; + +export function logsSamplingRule(spec: LogsSamplingRule): LogsSamplingRule { + return markResourceKind(spec, "logs-sampling-rule"); +} diff --git a/src/resources/order.test.ts b/src/resources/order.test.ts index 2809640..c650ba8 100644 --- a/src/resources/order.test.ts +++ b/src/resources/order.test.ts @@ -15,6 +15,7 @@ import { experimentHoldoutResource } from "./experiment-holdout/index.js"; import { experimentSavedMetricResource } from "./experiment-saved-metric/index.js"; import { featureFlagResource } from "./feature-flag/index.js"; import { insightResource } from "./insight/index.js"; +import { logsSamplingRuleResource } from "./logs-sampling-rule/index.js"; import { projectSettingsResource } from "./project-settings/index.js"; import { propertyGroupResource } from "./property-group/index.js"; @@ -183,6 +184,7 @@ describe("RESOURCES (real registry)", () => { experimentSavedMetricResource, featureFlagResource, insightResource, + logsSamplingRuleResource, projectSettingsResource, propertyGroupResource, ].map((r) => r.name), From 53def5aa72e3b9190215df32d9b0298479cf4c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Ledwo=C5=84?= Date: Fri, 24 Jul 2026 09:46:21 +0200 Subject: [PATCH 3/3] test(smoke): seed logs sampling rule Seed one disabled logs sampling rule per smoke run, add logs-sampling-rules to SMOKE_KINDS, and wire --logs-sampling-rule= into smoke-cleanup.ts. Verified in isolation: seed -> apply -> pull --all-rows -> tag-back -> dry-run no-op -> cleanup. (Full `pnpm smoke` is blocked upstream of this resource by a pre-existing gap: `actions` is in SMOKE_KINDS but has no pull codegen on pl/spec-migration, so pull fails at args validation.) Co-Authored-By: Claude Fable 5 --- scripts/smoke-cleanup.ts | 18 ++++++++++++++++++ scripts/smoke.sh | 21 +++++++++++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-cleanup.ts b/scripts/smoke-cleanup.ts index 2af602e..17b04ad 100644 --- a/scripts/smoke-cleanup.ts +++ b/scripts/smoke-cleanup.ts @@ -82,6 +82,12 @@ import { pruneAction, } from "../src/resources/action/pipeline.js"; +import { listManagedLogsSamplingRules } from "../src/resources/logs-sampling-rule/client.js"; +import { + logsSamplingRuleKeyFromServer, + pruneLogsSamplingRule, +} from "../src/resources/logs-sampling-rule/pipeline.js"; + import type { ClientConfig } from "../src/client/config.js"; type Args = { @@ -96,6 +102,7 @@ type Args = { "property-group"?: string; cohort?: string; endpoint?: string; + "logs-sampling-rule"?: string; }; const ARG_KEYS: Array = [ @@ -110,6 +117,7 @@ const ARG_KEYS: Array = [ "property-group", "cohort", "endpoint", + "logs-sampling-rule", ]; function parseArgs(argv: string[]): Args { @@ -270,6 +278,16 @@ async function main(): Promise { (c, row) => pruneEndpoint(c, row), ); } + if (args["logs-sampling-rule"]) { + await deleteByKey( + "logs-sampling-rule", + args["logs-sampling-rule"], + config, + listManagedLogsSamplingRules, + (row) => logsSamplingRuleKeyFromServer(row), + (c, row) => pruneLogsSamplingRule(c, row), + ); + } } main().catch((err) => { diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 56e6a8c..3abc427 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -63,6 +63,7 @@ SAVED_METRIC_KEY="smoke-metric-${STAMP}" EXPERIMENT_KEY="smoke-experiment-${STAMP}" COHORT_KEY="smoke-cohort-${STAMP}" ENDPOINT_KEY="smoke-endpoint-${STAMP}" +LOGS_SAMPLING_RULE_KEY="smoke-logs-sampling-${STAMP}" # Names that round-trip cleanly through pull's slug-from-name. We use the # event-definition `name` as both the spec key AND the on-the-wire event @@ -71,7 +72,7 @@ EVENT_NAME="${EVENT_DEFINITION_KEY}" # Resource kinds we'll seed and pull. Used both in --kind for pull and in # the no-op assertion's filter (we don't want unrelated kinds dirtying it). -SMOKE_KINDS="insights,dashboards,property-groups,event-definitions,actions,feature-flags,experiment-holdouts,experiment-saved-metrics,experiments,cohorts,endpoints" +SMOKE_KINDS="insights,dashboards,property-groups,event-definitions,actions,feature-flags,experiment-holdouts,experiment-saved-metrics,experiments,cohorts,endpoints,logs-sampling-rules" cleanup() { echo @@ -88,6 +89,7 @@ cleanup() { "--property-group=${PROPERTY_GROUP_KEY}" \ "--cohort=${COHORT_KEY}" \ "--endpoint=${ENDPOINT_KEY}" \ + "--logs-sampling-rule=${LOGS_SAMPLING_RULE_KEY}" \ || echo "smoke: cleanup hit an error (continuing)" echo "smoke: removing workdir $WORK_DIR" rm -rf "$WORK_DIR" @@ -153,7 +155,8 @@ mkdir -p \ "$SEED_DIR/experiment-saved-metrics" \ "$SEED_DIR/experiments" \ "$SEED_DIR/cohorts" \ - "$SEED_DIR/endpoints" + "$SEED_DIR/endpoints" \ + "$SEED_DIR/logs-sampling-rules" # Insight — referenced by the dashboard tile below. cat > "$SEED_DIR/insights/smoke-insight.ts" < "$SEED_DIR/logs-sampling-rules/smoke-logs-sampling.ts" <