From 1600b3e8538aa848aadb56c679ccb5d322eab62d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 9 Sep 2026 01:45:59 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(ga4):=20collector=20foundation=20?= =?UTF-8?q?=E2=80=94=20env,=20schema,=20and=20delta=20reconciliation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for a Google Analytics 4 integration, modeled on the Cloudflare analytics collector: poll a third-party API on a cron and ship the results as OTLP metrics through the ingest gateway, so the metric explorer, dashboard builder and alerting all work on the data with no new query paths. The one place GA4 cannot follow Cloudflare is restatement. Cloudflare's 5-minute buckets are final once written, so an append-only watermark is enough. GA4 keeps revising `dateHour` rows for ~48h, and `metrics_sum` is a plain MergeTree with no dedupe — re-polling an hour and writing the new value again would leave two rows at the same timestamp, and every reducer would then read wrong: `sum` double-counts, `avg` blends stale with fresh, `max` breaks on a downward revision. So nothing is written as an absolute value. Each series records what it has already emitted for a bucket, and a re-poll emits only the difference as a DELTA-temporality, non-monotonic sum. `sum(Value)` per bucket is then exactly GA4's current answer however many times the hour is revised, and a downward revision is simply a negative delta. The ledger holds one row per (org, property, dataset, bucket) with a JSON map of seriesHash -> value rather than a row per series: ~288 rows per property instead of ~9k, which is the difference between ~29k and ~930k rows on the primary at 100 properties. Also resolved here: GA4's `dateHour` is expressed in the property's reporting timezone, not UTC, and says so nowhere in the response. Left unconverted, every bucket lands at the wrong instant — consistently, invisibly, and by a whole number of hours. The property timezone is now cached on the state row and the conversion goes through the platform tz database. Extracted `otlp.ts` and the cardinality-folding helpers out of the Cloudflare collector into `integrations/shared/`, unchanged in behaviour — both are provider-agnostic and the GA4 collector needs them verbatim. --- apps/alerting/src/worker.ts | 8 +- apps/api/src/platform/Env.ts | 37 + apps/api/src/resources/env.ts | 2 + .../CloudflareAnalyticsService.test.ts | 2 +- .../CloudflareAnalyticsService.ts | 2 +- .../integrations/GoogleAnalyticsApi.ts | 260 + .../cloudflare-analytics/mapping.ts | 17 +- .../integrations/google-analytics/datasets.ts | 223 + .../integrations/google-analytics/mapping.ts | 132 + .../google-analytics/reconcile.test.ts | 199 + .../google-analytics/reconcile.ts | 220 + .../google-analytics/timezone.test.ts | 73 + .../integrations/google-analytics/timezone.ts | 101 + .../integrations/shared/cardinality.ts | 29 + .../otlp.test.ts | 0 .../{cloudflare-analytics => shared}/otlp.ts | 13 +- .../0055_google_analytics_integration.sql | 36 + packages/db/drizzle/meta/0055_snapshot.json | 9105 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/schema/google-analytics-ledger.ts | 52 + .../db/src/schema/google-analytics-state.ts | 75 + packages/db/src/schema/index.ts | 2 + packages/infra/src/env.test.ts | 26 + packages/infra/src/env.ts | 17 + 24 files changed, 10612 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/services/integrations/GoogleAnalyticsApi.ts create mode 100644 apps/api/src/services/integrations/google-analytics/datasets.ts create mode 100644 apps/api/src/services/integrations/google-analytics/mapping.ts create mode 100644 apps/api/src/services/integrations/google-analytics/reconcile.test.ts create mode 100644 apps/api/src/services/integrations/google-analytics/reconcile.ts create mode 100644 apps/api/src/services/integrations/google-analytics/timezone.test.ts create mode 100644 apps/api/src/services/integrations/google-analytics/timezone.ts create mode 100644 apps/api/src/services/integrations/shared/cardinality.ts rename apps/api/src/services/integrations/{cloudflare-analytics => shared}/otlp.test.ts (100%) rename apps/api/src/services/integrations/{cloudflare-analytics => shared}/otlp.ts (89%) create mode 100644 packages/db/drizzle/0055_google_analytics_integration.sql create mode 100644 packages/db/drizzle/meta/0055_snapshot.json create mode 100644 packages/db/src/schema/google-analytics-ledger.ts create mode 100644 packages/db/src/schema/google-analytics-state.ts diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index f397658eb..785b69d4b 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -26,6 +26,7 @@ import { appUrlsEnv, authEnv, cloudflareOAuthEnv, + googleAnalyticsOAuthEnv, ingestKeyCryptoEnv, merge, optionalPlain, @@ -105,12 +106,13 @@ const configuredEnv = (stage: MapleStage) => optionalSecret("AUTUMN_SECRET_KEY"), optionalSecret("INTERNAL_SERVICE_TOKEN"), // The alerting worker is where incidents open and resolve, so it is the one - // that sends push (platform/Apns.ts) — and it runs the Cloudflare analytics - // and PlanetScale inventory pollers, each of which resolves and refreshes - // per-org OAuth tokens with the same config the api worker uses. + // that sends push (platform/Apns.ts) — and it runs the Cloudflare analytics, + // PlanetScale inventory and Google Analytics pollers, each of which resolves + // and refreshes per-org OAuth tokens with the same config the api worker uses. apnsEnv, cloudflareOAuthEnv, planetScaleOAuthEnv, + googleAnalyticsOAuthEnv, ) /** diff --git a/apps/api/src/platform/Env.ts b/apps/api/src/platform/Env.ts index 9a053c928..a7d6637dd 100644 --- a/apps/api/src/platform/Env.ts +++ b/apps/api/src/platform/Env.ts @@ -145,6 +145,21 @@ export interface EnvConfig { * request may only name a subset of them. */ readonly PLANETSCALE_OAUTH_SCOPES: string + readonly GOOGLE_OAUTH_CLIENT_ID: Option.Option + /** Required alongside the client id — Google web-application clients are confidential. */ + readonly GOOGLE_OAUTH_CLIENT_SECRET: Option.Option> + readonly GOOGLE_OAUTH_AUTHORIZE_URL: string + readonly GOOGLE_OAUTH_TOKEN_URL: string + readonly GOOGLE_OAUTH_REVOKE_URL: string + /** + * Space-delimited OAuth scopes. `analytics.readonly` is a Google SENSITIVE scope: the + * client needs brand review + OAuth verification before serving more than 100 users. + */ + readonly GOOGLE_OAUTH_SCOPES: string + /** GA4 Data API base (`runReport`) — overridable for tests. */ + readonly MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL: string + /** GA4 Admin API base (`accountSummaries`) — overridable for tests. */ + readonly MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL: string } const portConfig = Config.number("PORT").pipe(Config.withDefault(3472)) @@ -296,6 +311,28 @@ const envConfig = Config.all({ "PLANETSCALE_OAUTH_SCOPES", "user:read_organizations organization:read_organization organization:read_databases organization:read_branches organization:read_backups organization:read_comments organization:read_deploy_requests branch:read_branch", ), + GOOGLE_OAUTH_CLIENT_ID: optionalString("GOOGLE_OAUTH_CLIENT_ID"), + GOOGLE_OAUTH_CLIENT_SECRET: optionalRedacted("GOOGLE_OAUTH_CLIENT_SECRET"), + GOOGLE_OAUTH_AUTHORIZE_URL: stringWithDefault( + "GOOGLE_OAUTH_AUTHORIZE_URL", + "https://accounts.google.com/o/oauth2/v2/auth", + ), + GOOGLE_OAUTH_TOKEN_URL: stringWithDefault("GOOGLE_OAUTH_TOKEN_URL", "https://oauth2.googleapis.com/token"), + GOOGLE_OAUTH_REVOKE_URL: stringWithDefault("GOOGLE_OAUTH_REVOKE_URL", "https://oauth2.googleapis.com/revoke"), + // Read-only analytics data + the property list needed to discover what to poll. + // `analytics.readonly` alone covers both the Data API and Admin API reads we make. + GOOGLE_OAUTH_SCOPES: stringWithDefault( + "GOOGLE_OAUTH_SCOPES", + "https://www.googleapis.com/auth/analytics.readonly", + ), + MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL: stringWithDefault( + "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL", + "https://analyticsdata.googleapis.com/v1beta", + ), + MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL: stringWithDefault( + "MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL", + "https://analyticsadmin.googleapis.com/v1beta", + ), }) const makeEnv = Effect.gen(function* () { diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index a0cde766f..634f9d1a8 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -17,6 +17,7 @@ import { authEnv, cloudflareOAuthEnv, derived, + googleAnalyticsOAuthEnv, ingestKeyCryptoEnv, merge, optionalPlain, @@ -109,4 +110,5 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => optionalPlain("GITHUB_API_BASE_URL"), cloudflareOAuthEnv, planetScaleOAuthEnv, + googleAnalyticsOAuthEnv, ) diff --git a/apps/api/src/services/integrations/CloudflareAnalyticsService.test.ts b/apps/api/src/services/integrations/CloudflareAnalyticsService.test.ts index cd6e7e916..87433f7d0 100644 --- a/apps/api/src/services/integrations/CloudflareAnalyticsService.test.ts +++ b/apps/api/src/services/integrations/CloudflareAnalyticsService.test.ts @@ -28,7 +28,7 @@ import { CloudflareOAuthService } from "@/services/auth/CloudflareOAuthService" import { OrgClickHouseSettingsService } from "@/services/org/OrgClickHouseSettingsService" import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" import type { MetricGaugeRow, MetricSumRow } from "./cloudflare-analytics/mapping" -import type { OtlpMetricsPayload } from "./cloudflare-analytics/otlp" +import type { OtlpMetricsPayload } from "./shared/otlp" import { compiledQueryOf } from "@maple/query-engine/execution" const trackedDbs: TestDb[] = [] diff --git a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts index 1a33cc1d5..3aa6ca1d3 100644 --- a/apps/api/src/services/integrations/CloudflareAnalyticsService.ts +++ b/apps/api/src/services/integrations/CloudflareAnalyticsService.ts @@ -97,7 +97,7 @@ import { mapWorkersGroups, type CloudflareMetricRows, } from "./cloudflare-analytics/mapping" -import { metricRowsToOtlp } from "./cloudflare-analytics/otlp" +import { metricRowsToOtlp } from "./shared/otlp" import { accountAnalyticsDocument, DatasetSettings, diff --git a/apps/api/src/services/integrations/GoogleAnalyticsApi.ts b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts new file mode 100644 index 000000000..6b455ddb9 --- /dev/null +++ b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts @@ -0,0 +1,260 @@ +// BOUNDARY: This module owns unparsed external values and narrows them before domain use. +/** + * Thin wrapper over the two Google Analytics 4 REST surfaces the collector needs: + * + * - **Admin API** `accountSummaries.list` — property discovery. One call enumerates every account + * the grant covers and the properties under each, so there is no per-account fan-out. + * - **Data API** `properties/{id}:runReport` — the actual numbers, one call per (property, dataset). + * + * Unlike {@link CloudflareApi} this is NOT a lazy dynamic-import facade. That indirection exists + * purely because the distilled Cloudflare SDK is a ~2.4MB module graph; GA4 is plain REST over the + * ambient `HttpClient`, so there is nothing heavy to defer and the extra hop would only obscure. + * + * Error mapping is the contract the poll loop reads: + * - 401, and 403 whose reason names the credential, are {@link IntegrationsRevokedError} — the + * grant is gone and the connection must be stamped revoked. + * - 403 `RESOURCE_EXHAUSTED`-shaped quota denials and 429 keep `status` on + * {@link IntegrationsUpstreamError} so the caller can hold its lease through a backoff instead + * of re-depleting the property's token budget. + * - Everything else is a plain upstream failure: the watermark simply does not advance. + */ +import { IntegrationsRevokedError, IntegrationsUpstreamError } from "@maple/domain/http" +import { Effect, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" + +/** GA4 quota denial — the caller backs off rather than retrying within the tick. */ +export const GA_QUOTA_STATUS = 429 + +const PropertySummary = Schema.Struct({ + // "properties/123456789" + property: Schema.String, + displayName: Schema.optionalKey(Schema.String), + propertyType: Schema.optionalKey(Schema.String), +}) + +const AccountSummary = Schema.Struct({ + displayName: Schema.optionalKey(Schema.String), + propertySummaries: Schema.optionalKey(Schema.Array(PropertySummary)), +}) + +const AccountSummariesResponse = Schema.Struct({ + accountSummaries: Schema.optionalKey(Schema.Array(AccountSummary)), + nextPageToken: Schema.optionalKey(Schema.String), +}) + +const MetricHeader = Schema.Struct({ + name: Schema.optionalKey(Schema.String), + type: Schema.optionalKey(Schema.String), +}) + +const ReportValue = Schema.Struct({ value: Schema.optionalKey(Schema.String) }) + +const ReportRow = Schema.Struct({ + dimensionValues: Schema.optionalKey(Schema.Array(ReportValue)), + metricValues: Schema.optionalKey(Schema.Array(ReportValue)), +}) + +/** + * `rows` is absent, not empty, when a report matches nothing — hence `optionalKey` throughout. + * `dimensionHeaders`/`metricHeaders` echo the request order, which is what lets the mapper pair a + * row's positional values back to names without trusting our own request-building twice. + */ +const RunReportResponse = Schema.Struct({ + dimensionHeaders: Schema.optionalKey(Schema.Array(Schema.Struct({ name: Schema.optionalKey(Schema.String) }))), + metricHeaders: Schema.optionalKey(Schema.Array(MetricHeader)), + rows: Schema.optionalKey(Schema.Array(ReportRow)), + rowCount: Schema.optionalKey(Schema.Number), +}) + +export type GoogleAnalyticsRunReportResponse = typeof RunReportResponse.Type + +const decodeAccountSummaries = Schema.decodeUnknown(AccountSummariesResponse) +const decodeRunReport = Schema.decodeUnknown(RunReportResponse) + +export interface GoogleAnalyticsProperty { + /** Bare id ("123456789"), with the API's "properties/" resource prefix stripped. */ + readonly propertyId: string + readonly propertyName: string | null + readonly accountName: string | null +} + +/** One `runReport` request, in the Data API's own vocabulary. */ +export interface RunReportRequest { + readonly dimensions: ReadonlyArray + readonly metrics: ReadonlyArray + /** Inclusive `YYYY-MM-DD` bounds, in the property's configured reporting timezone. */ + readonly startDate: string + readonly endDate: string + readonly limit?: number + /** Descending order-by on this metric — how a breakdown dataset takes its top N. */ + readonly orderByMetric?: string + /** Sent verbatim as the Data API's `dimensionFilter`. */ + readonly dimensionFilter?: unknown +} + +const upstream = (message: string, status?: number, cause?: unknown) => + new IntegrationsUpstreamError({ + message, + ...(status === undefined ? {} : { status }), + ...(cause === undefined ? {} : { cause }), + }) + +/** + * Google's error envelope: `{ error: { code, status, message } }`. `status` is the symbolic + * enum ("PERMISSION_DENIED", "RESOURCE_EXHAUSTED"), which is what distinguishes a dead grant + * from a quota denial — both arrive as HTTP 403. + */ +const errorStatusOf = (text: string): string | null => { + try { + const parsed = JSON.parse(text) as { error?: { status?: unknown } } + const status = parsed.error?.status + return typeof status === "string" ? status : null + } catch { + return null + } +} + +const classifyFailure = (httpStatus: number, text: string, label: string) => { + const symbolic = errorStatusOf(text) + const snippet = text.slice(0, 300) + // A 403 is overloaded: quota denials and dead grants share it. Only the credential-shaped + // ones may stamp the connection revoked — treating a quota denial as revoked would + // disconnect an org for being popular. + if (httpStatus === 401 || (httpStatus === 403 && symbolic !== "RESOURCE_EXHAUSTED")) { + return new IntegrationsRevokedError({ + message: `Google Analytics ${label} rejected the stored grant (${httpStatus}${symbolic ? ` ${symbolic}` : ""}) — reconnect required`, + }) + } + if (httpStatus === 429 || symbolic === "RESOURCE_EXHAUSTED") { + return upstream( + `Google Analytics ${label} quota exhausted (${httpStatus}${symbolic ? ` ${symbolic}` : ""})`, + GA_QUOTA_STATUS, + ) + } + return upstream(`Google Analytics ${label} returned ${httpStatus}: ${snippet}`, httpStatus) +} + +const authorized = (request: HttpClientRequest.HttpClientRequest, accessToken: string) => + request.pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${accessToken}`, + accept: "application/json", + }), + ) + +/** + * Every GA4 property the grant can see, across every account it covers. Paginated: the Admin API + * caps `pageSize` at 200 and a single agency grant can exceed that, so the loop is not optional. + */ +export const listProperties = Effect.fn("GoogleAnalyticsApi.listProperties")(function* (options: { + readonly accessToken: string + readonly adminBaseUrl: string + /** Safety stop so a pathological `nextPageToken` cycle cannot spin the tick. */ + readonly maxPages?: number +}) { + const httpClient = yield* HttpClient.HttpClient + const properties: Array = [] + let pageToken: string | undefined + const maxPages = options.maxPages ?? 10 + + for (let page = 0; page < maxPages; page++) { + const url = new URL(`${options.adminBaseUrl.replace(/\/+$/, "")}/accountSummaries`) + url.searchParams.set("pageSize", "200") + if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken) + + const response = yield* httpClient + .execute(authorized(HttpClientRequest.get(url.toString()), options.accessToken)) + .pipe( + Effect.annotateSpans("peer.service", "google-analytics-admin"), + Effect.catchTag("HttpClientError", (error) => + Effect.fail(upstream(`Google Analytics admin request failed: ${error.message}`, undefined, error)), + ), + ) + + if (response.status >= 300) { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* Effect.fail(classifyFailure(response.status, text, "Admin API")) + } + + const json = yield* response.json.pipe( + Effect.mapError(() => upstream("Google Analytics Admin API returned a non-JSON response")), + ) + const decoded = yield* decodeAccountSummaries(json).pipe( + Effect.mapError(() => upstream("Google Analytics Admin API returned an unexpected payload")), + ) + + for (const account of decoded.accountSummaries ?? []) { + for (const summary of account.propertySummaries ?? []) { + // "properties/123456789" → "123456789". A name that does not carry the prefix is + // not a property resource we understand, so it is skipped rather than guessed at. + const propertyId = summary.property.startsWith("properties/") + ? summary.property.slice("properties/".length) + : null + if (propertyId === null || propertyId === "") continue + properties.push({ + propertyId, + propertyName: summary.displayName ?? null, + accountName: account.displayName ?? null, + }) + } + } + + pageToken = decoded.nextPageToken + if (pageToken === undefined || pageToken === "") break + } + + return properties +}) + +/** One Data API `runReport` against a single property. */ +export const runReport = Effect.fn("GoogleAnalyticsApi.runReport")(function* (options: { + readonly accessToken: string + readonly dataBaseUrl: string + readonly propertyId: string + readonly request: RunReportRequest +}) { + const httpClient = yield* HttpClient.HttpClient + const { request } = options + const body = { + dimensions: request.dimensions.map((name) => ({ name })), + metrics: request.metrics.map((name) => ({ name })), + dateRanges: [{ startDate: request.startDate, endDate: request.endDate }], + ...(request.limit === undefined ? {} : { limit: String(request.limit) }), + ...(request.orderByMetric === undefined + ? {} + : { orderBys: [{ metric: { metricName: request.orderByMetric }, desc: true }] }), + ...(request.dimensionFilter === undefined ? {} : { dimensionFilter: request.dimensionFilter }), + // Google's own "(other)" bucket silently replaces the tail once a report exceeds its + // cardinality limit. Asking for the totals row would not tell us it happened, so instead + // the caller caps with `limit` and folds its own explicit remainder — see the mapper. + keepEmptyRows: false, + } + + const url = `${options.dataBaseUrl.replace(/\/+$/, "")}/properties/${options.propertyId}:runReport` + const response = yield* httpClient + .execute( + authorized(HttpClientRequest.post(url), options.accessToken).pipe( + HttpClientRequest.bodyJsonUnsafe(body), + ), + ) + .pipe( + Effect.annotateSpans("peer.service", "google-analytics-data"), + Effect.catchTag("HttpClientError", (error) => + Effect.fail(upstream(`Google Analytics data request failed: ${error.message}`, undefined, error)), + ), + ) + + if (response.status >= 300) { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* Effect.fail(classifyFailure(response.status, text, "Data API")) + } + + const json = yield* response.json.pipe( + Effect.mapError(() => upstream("Google Analytics Data API returned a non-JSON response")), + ) + return yield* decodeRunReport(json).pipe( + Effect.mapError(() => upstream("Google Analytics Data API returned an unexpected payload")), + ) +}) + +export type GoogleAnalyticsApiError = IntegrationsUpstreamError | IntegrationsRevokedError diff --git a/apps/api/src/services/integrations/cloudflare-analytics/mapping.ts b/apps/api/src/services/integrations/cloudflare-analytics/mapping.ts index 25ed264ce..8c7666408 100644 --- a/apps/api/src/services/integrations/cloudflare-analytics/mapping.ts +++ b/apps/api/src/services/integrations/cloudflare-analytics/mapping.ts @@ -14,6 +14,7 @@ * sampling-adjusted by Cloudflare, so they pass through untouched. */ import { fmtMetricTs, type MetricGaugeRow, type MetricSumRow } from "@/services/warehouse/metric-rows" +import { foldTail, OTHER_BUCKET, topNKeys } from "../shared/cardinality" import type { DnsGroupDefinition, DurableObjectsGroupDefinition, @@ -139,16 +140,6 @@ export const MAX_DNS_QUERY_NAMES = 20 export const MAX_HTTP_PATHS = 50 /** Countries are naturally bounded (~250); the cap is a safety net, not a design constraint. */ export const MAX_COUNTRIES = 50 -export const OTHER_BUCKET = "other" - -/** Top-N keys by weight; ties break lexicographically so folding is deterministic across runs. */ -export const topNKeys = (weights: ReadonlyMap, n: number): ReadonlySet => - new Set( - [...weights.entries()] - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, n) - .map(([key]) => key), - ) const httpResourceAttrs = (orgId: string, zoneId: string, zoneName: string): Attrs => ({ maple_org_id: orgId, @@ -265,12 +256,6 @@ export const mapHttpGroups = (input: MapHttpGroupsInput): CloudflareMetricRows = return { sumRows, gaugeRows } } -/** Weight-rank an unbounded dimension across a window and fold the tail into {@link OTHER_BUCKET}. */ -const foldTail = (weights: ReadonlyMap, n: number): ((key: string) => string) => { - const top = topNKeys(weights, n) - return (key) => (top.has(key) ? key : OTHER_BUCKET) -} - /** Cap stored path length so one pathological URL can't bloat the attribute map. */ const MAX_PATH_LEN = 200 diff --git a/apps/api/src/services/integrations/google-analytics/datasets.ts b/apps/api/src/services/integrations/google-analytics/datasets.ts new file mode 100644 index 000000000..e38071094 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/datasets.ts @@ -0,0 +1,223 @@ +/** + * The GA4 report registry. One entry per Data API `runReport` call the collector makes per + * property per tick; one generic poll pipeline drives them all. + * + * Metric naming follows the Cloudflare collector's convention, and the `.by_*` suffix on + * breakdowns is load-bearing rather than cosmetic. `channels`, `geo` and `device` each report the + * SAME underlying total sliced a different way, so if they all wrote `google_analytics.sessions` + * a chart with no group-by would silently show four times the real session count. The suffix + * keeps each slice its own metric, exactly as `cloudflare.http.requests.by_country` does. + * + * Every value is emitted as a DELTA-temporality sum against the reconciliation ledger — see + * `reconcile.ts` for why, and chart them with `sum`, never `rate`/`increase`. + */ + +/** Instrumentation scope for every row this collector writes. */ +export const SCOPE_NAME = "@maple/google-analytics" + +/** Shared metric-name prefix. Dashboard-template readiness gates on exactly this string. */ +export const METRIC_PREFIX = "google_analytics." + +export interface GaMetricDef { + /** The Data API metric name, sent verbatim in the request. */ + readonly ga: string + /** The Maple metric name written to `metrics_sum`. */ + readonly metric: string + readonly unit: string + readonly description: string +} + +export interface GaBreakdown { + /** The Data API dimension name, sent alongside `dateHour`. */ + readonly dimension: string + /** Attribute key on the emitted metric row. */ + readonly attributeKey: string + /** + * Cardinality cap. `Attributes` sits in the metrics tables' sorting key, so an unbounded + * dimension degrades every read of the metric, not just this breakdown. The tail folds into + * one `other` series so the parts still sum to the unbroken total. + */ + readonly maxValues: number + /** Metric the tail-fold ranks by — the "heaviest" values are the ones worth keeping. */ + readonly rankBy: string + /** Truncation for a single pathological value (a 4KB URL). */ + readonly maxValueLength?: number +} + +export interface GaDatasetDef { + readonly id: string + /** Null for the unbroken totals dataset. */ + readonly breakdown: GaBreakdown | null + readonly metrics: ReadonlyArray +} + +const SESSIONS: GaMetricDef = { + ga: "sessions", + metric: "google_analytics.sessions", + unit: "{sessions}", + description: "Sessions", +} + +/** + * Unbroken totals. The headline numbers, and the only dataset whose metrics carry no breakdown + * attribute — which is what makes them safe to chart without a group-by. + */ +const trafficDataset: GaDatasetDef = { + id: "traffic", + breakdown: null, + metrics: [ + SESSIONS, + { + ga: "activeUsers", + metric: "google_analytics.active_users", + unit: "{users}", + description: "Active users", + }, + { + ga: "newUsers", + metric: "google_analytics.new_users", + unit: "{users}", + description: "First-time users", + }, + { + ga: "screenPageViews", + metric: "google_analytics.page_views", + unit: "{page_views}", + description: "Page and screen views", + }, + { + ga: "engagedSessions", + metric: "google_analytics.engaged_sessions", + unit: "{sessions}", + description: "Sessions that lasted over 10s, had a key event, or had 2+ page views", + }, + { + ga: "userEngagementDuration", + metric: "google_analytics.engagement_duration", + unit: "s", + description: "Total time the site was in the foreground", + }, + ], +} + +/** Where traffic came from — GA4's own channel grouping, not a re-derivation of source/medium. */ +const channelsDataset: GaDatasetDef = { + id: "channels", + breakdown: { + dimension: "sessionDefaultChannelGroup", + attributeKey: "google_analytics.channel_group", + // GA4's default channel grouping is a closed set of ~17 values; the cap is a safety net. + maxValues: 30, + rankBy: "sessions", + }, + metrics: [ + { ...SESSIONS, metric: "google_analytics.sessions.by_channel" }, + { + ga: "activeUsers", + metric: "google_analytics.active_users.by_channel", + unit: "{users}", + description: "Active users by acquisition channel", + }, + ], +} + +const geoDataset: GaDatasetDef = { + id: "geo", + breakdown: { + // `countryId`, not `country`: the former is ISO 3166-1 alpha-2, which matches the + // `geo.country_iso_code` semconv key and the Cloudflare collector's spelling. `country` + // returns localized display names, which would split "United States" per viewer locale. + dimension: "countryId", + attributeKey: "geo.country_iso_code", + maxValues: 50, + rankBy: "sessions", + }, + metrics: [{ ...SESSIONS, metric: "google_analytics.sessions.by_country" }], +} + +const deviceDataset: GaDatasetDef = { + id: "device", + breakdown: { + dimension: "deviceCategory", + attributeKey: "google_analytics.device_category", + // desktop / mobile / tablet / smart tv — bounded by GA4 itself. + maxValues: 10, + rankBy: "sessions", + }, + metrics: [{ ...SESSIONS, metric: "google_analytics.sessions.by_device" }], +} + +const pagesDataset: GaDatasetDef = { + id: "pages", + breakdown: { + dimension: "pagePath", + attributeKey: "url.path", + // The highest-cardinality dimension here by a wide margin, and the most valuable — hence a + // higher cap than the others rather than the same one. + maxValues: 100, + rankBy: "screenPageViews", + maxValueLength: 200, + }, + metrics: [ + { + ga: "screenPageViews", + metric: "google_analytics.page_views.by_page", + unit: "{page_views}", + description: "Page views by path", + }, + { + ga: "activeUsers", + metric: "google_analytics.active_users.by_page", + unit: "{users}", + description: "Active users by path", + }, + ], +} + +const eventsDataset: GaDatasetDef = { + id: "events", + breakdown: { + dimension: "eventName", + attributeKey: "google_analytics.event_name", + maxValues: 100, + rankBy: "eventCount", + maxValueLength: 100, + }, + metrics: [ + { + ga: "eventCount", + metric: "google_analytics.event_count.by_event", + unit: "{events}", + description: "Events by name", + }, + { + ga: "keyEvents", + metric: "google_analytics.key_events.by_event", + unit: "{events}", + description: "Key events (conversions) by name", + }, + ], +} + +export const DATASETS: ReadonlyArray = [ + trafficDataset, + channelsDataset, + geoDataset, + deviceDataset, + pagesDataset, + eventsDataset, +] + +export const DATASET_BY_ID: ReadonlyMap = new Map( + DATASETS.map((dataset) => [dataset.id, dataset]), +) + +/** + * Reserved state rows, which are not datasets and must never be polled as one. + * The discovery anchor holds grant-wide `discoveredAt`; see the schema's DISCOVERY ANCHOR note. + */ +export const DISCOVERY_DATASET = "__discovery__" +export const DISCOVERY_PROPERTY_ID = "" + +/** Service name for a property's rows — mirrors Cloudflare's `cloudflare/{zoneName}`. */ +export const serviceNameFor = (propertyId: string): string => `google-analytics/${propertyId}` diff --git a/apps/api/src/services/integrations/google-analytics/mapping.ts b/apps/api/src/services/integrations/google-analytics/mapping.ts new file mode 100644 index 000000000..462dcf0c0 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/mapping.ts @@ -0,0 +1,132 @@ +/** + * Pure mapping from a decoded GA4 `runReport` response to flat series points. + * + * Deliberately does NOT produce metric rows: what gets written depends on the reconciliation + * ledger (see `reconcile.ts`), so this stage stops at "here is the value GA4 currently reports + * for this series in this hour" and leaves the delta arithmetic downstream. + * + * Two things are resolved here that the rest of the pipeline then never has to think about: + * - `dateHour` is in the property's reporting timezone, so it is converted to a UTC instant. + * - The breakdown dimension is capped and its tail folded, so `Attributes` — which sits in the + * metrics tables' sorting key — stays bounded. + */ +import type { GoogleAnalyticsRunReportResponse } from "../GoogleAnalyticsApi" +import { foldTail, OTHER_BUCKET } from "../shared/cardinality" +import type { GaDatasetDef, GaMetricDef } from "./datasets" +import { dateHourToUtcMs } from "./timezone" + +/** GA4's own overflow bucket, folded into ours so the two do not appear as separate series. */ +const GA_OTHER_ROW = "(other)" + +export interface GaSeriesPoint { + /** UTC epoch ms of the hour bucket's start. */ + readonly bucketMs: number + readonly metric: GaMetricDef + readonly attributes: Record + readonly value: number +} + +/** + * Stable identity for a series within a (property, dataset, bucket), used as the ledger key. + * Attribute order is normalized so a key never depends on object insertion order. + */ +export const seriesKey = (metricName: string, attributes: Record): string => { + const entries = Object.entries(attributes).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return entries.length === 0 + ? metricName + : `${metricName} ${entries.map(([key, value]) => `${key}=${value}`).join(" ")}` +} + +const numberOf = (raw: string | undefined): number => { + if (raw === undefined || raw === "") return 0 + const parsed = Number(raw) + // GA4 returns metric values as strings; a non-numeric one is a contract break, and counting + // it as zero is safer than letting NaN reach the warehouse and poison a bucket's sum. + return Number.isFinite(parsed) ? parsed : 0 +} + +const truncate = (value: string, max: number | undefined): string => + max !== undefined && value.length > max ? value.slice(0, max) : value + +const indexOfHeader = ( + headers: ReadonlyArray<{ readonly name?: string }> | undefined, + name: string, +): number => (headers ?? []).findIndex((header) => header.name === name) + +/** + * Map one report. Rows whose `dateHour` cannot be placed on the timeline (malformed, or an + * unknown property timezone) are dropped rather than guessed at — a missing point is recoverable + * on the next reconcile pass, a misplaced one is not. + */ +export const mapReport = (options: { + readonly dataset: GaDatasetDef + readonly response: GoogleAnalyticsRunReportResponse + readonly timeZone: string +}): ReadonlyArray => { + const { dataset, response, timeZone } = options + const rows = response.rows ?? [] + if (rows.length === 0) return [] + + const hourIndex = indexOfHeader(response.dimensionHeaders, "dateHour") + if (hourIndex < 0) return [] + + const breakdown = dataset.breakdown + const breakdownIndex = breakdown === null ? -1 : indexOfHeader(response.dimensionHeaders, breakdown.dimension) + // The report echoes back the dimensions we asked for; a response missing the breakdown column + // is not one we can attribute, so emitting it unbroken would double-count against `traffic`. + if (breakdown !== null && breakdownIndex < 0) return [] + + const metricIndexes = dataset.metrics.map((metric) => ({ + metric, + index: indexOfHeader(response.metricHeaders, metric.ga), + })) + + const dimensionValue = (row: (typeof rows)[number], index: number): string => + row.dimensionValues?.[index]?.value ?? "" + + // Pass 1: rank the breakdown values across the whole window, so the surviving top-N is stable + // for every bucket in it. Ranking per bucket instead would churn the series set hour to hour. + let fold: (key: string) => string = (key) => key + if (breakdown !== null) { + const rankIndex = indexOfHeader(response.metricHeaders, breakdown.rankBy) + const weights = new Map() + for (const row of rows) { + const raw = dimensionValue(row, breakdownIndex) + if (raw === "" || raw === GA_OTHER_ROW) continue + const value = truncate(raw, breakdown.maxValueLength) + const weight = rankIndex < 0 ? 1 : numberOf(row.metricValues?.[rankIndex]?.value) + weights.set(value, (weights.get(value) ?? 0) + weight) + } + fold = foldTail(weights, breakdown.maxValues) + } + + // Pass 2: accumulate, because folding merges many raw values into one `other` series and the + // same (bucket, series) must arrive at the ledger exactly once. + const accumulated = new Map() + for (const row of rows) { + const bucketMs = dateHourToUtcMs(dimensionValue(row, hourIndex), timeZone) + if (bucketMs === null) continue + + let attributes: Record = {} + if (breakdown !== null) { + const raw = dimensionValue(row, breakdownIndex) + const folded = + raw === "" || raw === GA_OTHER_ROW ? OTHER_BUCKET : fold(truncate(raw, breakdown.maxValueLength)) + attributes = { [breakdown.attributeKey]: folded } + } + + for (const { metric, index } of metricIndexes) { + if (index < 0) continue + const value = numberOf(row.metricValues?.[index]?.value) + const key = `${bucketMs} ${seriesKey(metric.metric, attributes)}` + const existing = accumulated.get(key) + if (existing === undefined) { + accumulated.set(key, { bucketMs, metric, attributes, value }) + } else { + accumulated.set(key, { ...existing, value: existing.value + value }) + } + } + } + + return [...accumulated.values()] +} diff --git a/apps/api/src/services/integrations/google-analytics/reconcile.test.ts b/apps/api/src/services/integrations/google-analytics/reconcile.test.ts new file mode 100644 index 000000000..ca1fbc3f5 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/reconcile.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest" +import { DATASET_BY_ID } from "./datasets" +import { mapReport } from "./mapping" +import { type LedgerBucket, parseLedger, reconcile, serializeLedger } from "./reconcile" + +const traffic = DATASET_BY_ID.get("traffic")! +const channels = DATASET_BY_ID.get("channels")! + +const HOUR = 3_600_000 +const BUCKET = Date.parse("2026-09-09T14:00:00.000Z") + +/** A `runReport` response for the traffic dataset with a single hour of sessions. */ +const trafficResponse = (sessions: number) => ({ + dimensionHeaders: [{ name: "dateHour" }], + metricHeaders: [{ name: "sessions" }], + rows: [{ dimensionValues: [{ value: "2026090914" }], metricValues: [{ value: String(sessions) }] }], +}) + +/** A `runReport` response for the channels dataset: one row per (hour, channel). */ +const channelsResponse = (perChannel: ReadonlyArray) => ({ + dimensionHeaders: [{ name: "dateHour" }, { name: "sessionDefaultChannelGroup" }], + metricHeaders: [{ name: "sessions" }, { name: "activeUsers" }], + rows: perChannel.map(([channel, sessions]) => ({ + dimensionValues: [{ value: "2026090914" }, { value: channel }], + metricValues: [{ value: String(sessions) }, { value: "0" }], + })), +}) + +const run = (options: { + readonly dataset: typeof traffic + readonly response: ReturnType | ReturnType + readonly ledger: ReadonlyArray +}) => + reconcile({ + orgId: "org_test", + propertyId: "123456789", + propertyName: "Example", + accountName: "Acme", + dataset: options.dataset, + points: mapReport({ dataset: options.dataset, response: options.response, timeZone: "UTC" }), + ledger: options.ledger, + coveredFromMs: BUCKET, + coveredToMs: BUCKET + HOUR, + }) + +/** What the warehouse would report for a bucket: every delta ever emitted for it, summed. */ +const bucketSum = (...results: ReadonlyArray>) => + results.flatMap((result) => result.rows).reduce((total, row) => total + row.value, 0) + +describe("reconcile", () => { + it("emits the raw value the first time a bucket is seen", () => { + const result = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }) + const sessions = result.rows.filter((row) => row.metric_name === "google_analytics.sessions") + expect(sessions).toHaveLength(1) + expect(sessions[0]!.value).toBe(100) + }) + + it("emits DELTA temporality, non-monotonic, at the bucket's own timestamp", () => { + const [row] = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }).rows + expect(row!.aggregation_temporality).toBe(1) + expect(row!.is_monotonic).toBe(false) + expect(row!.timestamp).toBe("2026-09-09 14:00:00.000") + expect(row!.service_name).toBe("google-analytics/123456789") + }) + + // The core contract: however many times GA4 revises an hour, the bucket's summed deltas + // equal GA4's latest answer — which is exactly what `sum(Value)` per bucket reads back. + it("sums to the revised value after an upward revision", () => { + const first = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }) + const second = run({ dataset: traffic, response: trafficResponse(140), ledger: first.ledger }) + + const delta = second.rows.filter((row) => row.metric_name === "google_analytics.sessions") + expect(delta).toHaveLength(1) + expect(delta[0]!.value).toBe(40) + expect(bucketSum(first, second)).toBe(140) + }) + + it("sums to the revised value after a DOWNWARD revision", () => { + const first = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }) + const second = run({ dataset: traffic, response: trafficResponse(60), ledger: first.ledger }) + + const delta = second.rows.filter((row) => row.metric_name === "google_analytics.sessions") + expect(delta[0]!.value).toBe(-40) + expect(bucketSum(first, second)).toBe(60) + }) + + it("stays correct across a long chain of revisions", () => { + let ledger: ReadonlyArray = [] + const results = [] + for (const value of [10, 25, 25, 24, 90, 3, 3, 117]) { + const result = run({ dataset: traffic, response: trafficResponse(value), ledger }) + ledger = result.ledger + results.push(result) + } + expect(bucketSum(...results)).toBe(117) + }) + + it("writes nothing at all when a re-poll is unchanged", () => { + const first = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }) + const second = run({ dataset: traffic, response: trafficResponse(100), ledger: first.ledger }) + expect(second.rows).toHaveLength(0) + expect(bucketSum(first, second)).toBe(100) + }) + + it("retracts a series that disappears from the report", () => { + // "Paid Social" is reported, then revised away entirely. Without a retraction its old + // value would linger in the bucket's sum forever. + const first = run({ + dataset: channels, + response: channelsResponse([ + ["Organic Search", 80], + ["Paid Social", 20], + ]), + ledger: [], + }) + const second = run({ + dataset: channels, + response: channelsResponse([["Organic Search", 80]]), + ledger: first.ledger, + }) + + const retraction = second.rows.find( + (row) => row.metric_attributes["google_analytics.channel_group"] === "Paid Social", + ) + expect(retraction?.value).toBe(-20) + expect(bucketSum(first, second)).toBe(80) + }) + + it("round-trips a breakdown value containing spaces", () => { + // The retraction path has to rebuild attributes from the ledger key, and GA4's channel + // groups are multi-word ("Organic Search", "Paid Social", "Cross-network"). + const first = run({ + dataset: channels, + response: channelsResponse([["Organic Search", 55]]), + ledger: [], + }) + const second = run({ dataset: channels, response: channelsResponse([]), ledger: first.ledger }) + + const retraction = second.rows.find((row) => row.metric_name === "google_analytics.sessions.by_channel") + expect(retraction?.metric_attributes["google_analytics.channel_group"]).toBe("Organic Search") + expect(retraction?.value).toBe(-55) + expect(bucketSum(first, second)).toBe(0) + }) + + it("zeroes a covered bucket when the report comes back empty", () => { + // GA4 was asked about this hour and answered "nothing" — a revision to zero, not a gap. + const first = run({ dataset: traffic, response: trafficResponse(100), ledger: [] }) + const second = run({ + dataset: traffic, + response: { dimensionHeaders: [{ name: "dateHour" }], metricHeaders: [{ name: "sessions" }], rows: [] }, + ledger: first.ledger, + }) + expect(second.rows.map((row) => row.value)).toEqual([-100]) + expect(bucketSum(first, second)).toBe(0) + }) + + it("leaves buckets outside the covered window untouched", () => { + const stale: LedgerBucket = { bucketMs: BUCKET - 10 * HOUR, emitted: { "google_analytics.sessions": 7 } } + const result = run({ dataset: traffic, response: trafficResponse(100), ledger: [stale] }) + + // No retraction for the old bucket — the report said nothing about it, which is not the + // same as saying it is zero. + expect(result.rows.every((row) => row.timestamp === "2026-09-09 14:00:00.000")).toBe(true) + expect(result.ledger).toContainEqual(stale) + }) + + it("drops zero-valued series from the ledger instead of accumulating them", () => { + const result = run({ + dataset: channels, + response: channelsResponse([ + ["Organic Search", 80], + ["Paid Social", 0], + ]), + ledger: [], + }) + const bucket = result.ledger.find((entry) => entry.bucketMs === BUCKET) + expect(Object.keys(bucket?.emitted ?? {})).toEqual([ + "google_analytics.sessions.by_channel google_analytics.channel_group=Organic Search", + ]) + }) +}) + +describe("ledger serialization", () => { + it("round-trips", () => { + const emitted = { "google_analytics.sessions": 12, "google_analytics.sessions.by_channel x=y": 3.5 } + expect(parseLedger(serializeLedger(emitted))).toEqual(emitted) + }) + + it("treats an absent or corrupt blob as nothing-emitted", () => { + expect(parseLedger(null)).toEqual({}) + expect(parseLedger("")).toEqual({}) + expect(parseLedger("{ not json")).toEqual({}) + expect(parseLedger("[1,2,3]")).toEqual({}) + }) + + it("discards non-finite values rather than letting NaN reach a delta", () => { + expect(parseLedger('{"a": 1, "b": "x", "c": null}')).toEqual({ a: 1 }) + }) +}) diff --git a/apps/api/src/services/integrations/google-analytics/reconcile.ts b/apps/api/src/services/integrations/google-analytics/reconcile.ts new file mode 100644 index 000000000..2f00e5121 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/reconcile.ts @@ -0,0 +1,220 @@ +/** + * Delta reconciliation — the piece that makes a revisable source safe to write into an + * append-only warehouse. + * + * `metrics_sum` is a plain MergeTree with no dedupe, and GA4 keeps revising `dateHour` rows for + * ~48h after the fact. Re-polling an hour and writing the new value again would leave two rows at + * the same timestamp, and every reducer would then read wrong: `sum` double-counts, `avg` blends + * stale with fresh, `max` breaks on a downward revision. + * + * So nothing is ever written as an absolute value. Each series remembers what it has already + * emitted for a bucket (the ledger), and a re-poll emits only the DIFFERENCE. `sum(Value)` per + * bucket is then exactly GA4's current answer, by construction, no matter how many times the hour + * is revisited — and a downward revision is simply a negative delta. This is what + * `AggregationTemporality = 1` (DELTA) means, and it is why these rows are emitted with + * `is_monotonic: false`: the corrections are genuinely allowed to be negative. + * + * Chart these metrics with `sum`. Never `rate` or `increase` — those assume cumulative + * temporality and would double-difference the data (see + * `packages/query-engine/src/query-builder/model.ts`). + */ +import { fmtMetricTs, type MetricSumRow } from "@/services/warehouse/metric-rows" +import type { GaDatasetDef } from "./datasets" +import { SCOPE_NAME, serviceNameFor } from "./datasets" +import { type GaSeriesPoint, seriesKey } from "./mapping" + +/** What has already been emitted for one (property, dataset, bucket): seriesKey → value. */ +export interface LedgerBucket { + readonly bucketMs: number + readonly emitted: Readonly> +} + +export interface ReconcileResult { + /** DELTA rows to ship. Series whose value is unchanged produce nothing. */ + readonly rows: ReadonlyArray + /** Ledger buckets to persist. A bucket that ends up empty is flagged for deletion. */ + readonly ledger: ReadonlyArray +} + +/** + * Parse a stored ledger blob. A corrupt or non-object blob decodes to "nothing emitted yet", + * which re-emits the bucket's full current value — a visible double-count in one hour, versus + * silently freezing that hour forever if we treated the failure as "already up to date". + */ +export const parseLedger = (json: string | null | undefined): Readonly> => { + if (json == null || json === "") return {} + try { + const parsed: unknown = JSON.parse(json) + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {} + const out: Record = {} + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === "number" && Number.isFinite(value)) out[key] = value + } + return out + } catch { + return {} + } +} + +export const serializeLedger = (emitted: Readonly>): string => JSON.stringify(emitted) + +const resourceAttributes = (options: { + readonly orgId: string + readonly propertyId: string + readonly propertyName: string | null + readonly accountName: string | null +}): Record => ({ + maple_org_id: options.orgId, + "service.name": serviceNameFor(options.propertyId), + "google_analytics.property.id": options.propertyId, + ...(options.propertyName == null ? {} : { "google_analytics.property.name": options.propertyName }), + ...(options.accountName == null ? {} : { "google_analytics.account.name": options.accountName }), +}) + +/** + * Reconcile one dataset's freshly-polled points against the ledger. + * + * `coveredFromMs`/`coveredToMs` bound the half-open range the report actually asked GA4 about. + * They are what make the "series disappeared" case safe: inside the window, a series present in + * the ledger but absent from the response has genuinely gone to zero (revised away, or folded + * into `other` as top-N membership shifted) and must be zeroed out with a negative delta, or its + * old value would linger in the bucket's sum forever. Outside the window we know nothing, so + * those buckets are passed through untouched. + */ +export const reconcile = (options: { + readonly orgId: string + readonly propertyId: string + readonly propertyName: string | null + readonly accountName: string | null + readonly dataset: GaDatasetDef + readonly points: ReadonlyArray + readonly ledger: ReadonlyArray + readonly coveredFromMs: number + readonly coveredToMs: number +}): ReconcileResult => { + const { points, coveredFromMs, coveredToMs } = options + const resource = resourceAttributes(options) + const serviceName = serviceNameFor(options.propertyId) + + const ledgerByBucket = new Map(options.ledger.map((bucket) => [bucket.bucketMs, bucket.emitted])) + + // Group the freshly-polled points by bucket so each bucket is reconciled as a unit — the + // disappeared-series check below is only sound against a bucket's complete new picture. + const freshByBucket = new Map>() + for (const point of points) { + if (point.bucketMs < coveredFromMs || point.bucketMs >= coveredToMs) continue + let bucket = freshByBucket.get(point.bucketMs) + if (bucket === undefined) { + bucket = new Map() + freshByBucket.set(point.bucketMs, bucket) + } + bucket.set(seriesKey(point.metric.metric, point.attributes), point) + } + + // A covered bucket the report said nothing about is still covered: GA4 was asked and answered + // "nothing here". Seeding it empty is what lets the retraction pass below zero it out — without + // this, an hour revised away entirely (or a breakdown that stopped receiving traffic) would + // keep its last value in the warehouse forever, since no fresh point would ever name it again. + for (const bucket of options.ledger) { + if (bucket.bucketMs < coveredFromMs || bucket.bucketMs >= coveredToMs) continue + if (!freshByBucket.has(bucket.bucketMs)) freshByBucket.set(bucket.bucketMs, new Map()) + } + + const rows: Array = [] + const nextLedger: Array = [] + + const emit = (bucketMs: number, point: GaSeriesPoint, delta: number) => { + const ts = fmtMetricTs(bucketMs) + rows.push({ + timestamp: ts, + start_timestamp: ts, + metric_name: point.metric.metric, + metric_description: point.metric.description, + metric_unit: point.metric.unit, + metric_attributes: point.attributes, + service_name: serviceName, + resource_schema_url: "", + resource_attributes: resource, + scope_schema_url: "", + scope_name: SCOPE_NAME, + scope_version: "", + scope_attributes: {}, + value: delta, + flags: 0, + exemplars_trace_id: [], + exemplars_span_id: [], + exemplars_timestamp: [], + exemplars_value: [], + exemplars_filtered_attributes: [], + // DELTA. Each row is an increment for its hour, not a running total. + aggregation_temporality: 1, + // A correction may be negative, so this sum is explicitly non-monotonic. It also keeps + // these rows out of the cumulative rate path, which selects on `IsMonotonic = 1`. + is_monotonic: false, + }) + } + + // Buckets the poll covered: emit the difference for every series, in either direction. + for (const [bucketMs, fresh] of freshByBucket) { + const previous = ledgerByBucket.get(bucketMs) ?? {} + const emitted: Record = {} + + for (const [key, point] of fresh) { + const delta = point.value - (previous[key] ?? 0) + if (delta !== 0) emit(bucketMs, point, delta) + // Zero-valued series are dropped from the ledger rather than recorded as 0: keeping + // them would grow the blob with every path that ever appeared once. + if (point.value !== 0) emitted[key] = point.value + } + + for (const [key, previousValue] of Object.entries(previous)) { + if (fresh.has(key) || previousValue === 0) continue + // Gone from the report, so it is now zero. Reconstruct just enough of the series to + // emit the retraction. + const series = parseSeriesKey(key, options.dataset) + if (series === null) continue + emit(bucketMs, { bucketMs, metric: series.metric, attributes: series.attributes, value: 0 }, -previousValue) + } + + nextLedger.push({ bucketMs, emitted }) + } + + // Buckets outside the covered window are left exactly as they were. + for (const bucket of options.ledger) { + if (!freshByBucket.has(bucket.bucketMs)) nextLedger.push(bucket) + } + + return { rows, ledger: nextLedger } +} + +/** + * Inverse of {@link seriesKey} for the retraction path. + * + * Not a general parser, and it must not become one: it works because a dataset has at most ONE + * breakdown, so the key is exactly `" ="` and the value is + * everything after the first `=` following a known attribute key. Splitting on whitespace instead + * would corrupt every value that contains a space — `sessionDefaultChannelGroup` alone yields + * "Organic Search", "Paid Social", "Cross-network". + * + * Returns null when the key does not belong to this dataset (a stale blob written by an older + * registry), which drops the retraction rather than emitting it against a guessed series. + */ +const parseSeriesKey = ( + key: string, + dataset: GaDatasetDef, +): { readonly metric: GaDatasetDef["metrics"][number]; readonly attributes: Record } | null => { + // Longest first: within a dataset one metric name can prefix another + // (`…page_views` vs `…page_views.by_page`), and the shorter would match wrongly. + const candidates = [...dataset.metrics].sort((a, b) => b.metric.length - a.metric.length) + for (const metric of candidates) { + if (!key.startsWith(metric.metric)) continue + const rest = key.slice(metric.metric.length) + if (rest === "") return { metric, attributes: {} } + const breakdown = dataset.breakdown + if (breakdown === null) continue + const prefix = ` ${breakdown.attributeKey}=` + if (!rest.startsWith(prefix)) continue + return { metric, attributes: { [breakdown.attributeKey]: rest.slice(prefix.length) } } + } + return null +} diff --git a/apps/api/src/services/integrations/google-analytics/timezone.test.ts b/apps/api/src/services/integrations/google-analytics/timezone.test.ts new file mode 100644 index 000000000..c7b648077 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/timezone.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { dateHourToUtcMs, utcMsToZonedDate } from "./timezone" + +const iso = (ms: number) => new Date(ms).toISOString() + +describe("dateHourToUtcMs", () => { + it("is the identity for a UTC property", () => { + expect(iso(dateHourToUtcMs("2026090914", "UTC")!)).toBe("2026-09-09T14:00:00.000Z") + }) + + it("shifts a fixed-offset zone by its offset", () => { + // Asia/Tokyo is UTC+9 year-round: 14:00 local is 05:00 UTC. + expect(iso(dateHourToUtcMs("2026090914", "Asia/Tokyo")!)).toBe("2026-09-09T05:00:00.000Z") + }) + + it("applies the summer offset for a DST zone in summer", () => { + // America/Los_Angeles is UTC-7 in September (PDT): 14:00 local is 21:00 UTC. + expect(iso(dateHourToUtcMs("2026090914", "America/Los_Angeles")!)).toBe("2026-09-09T21:00:00.000Z") + }) + + it("applies the winter offset for the same zone in winter", () => { + // ...and UTC-8 in January (PST): the same wall-clock hour is 22:00 UTC. A naive fixed + // offset would put this bucket an hour out for half the year. + expect(iso(dateHourToUtcMs("2026011514", "America/Los_Angeles")!)).toBe("2026-01-15T22:00:00.000Z") + }) + + it("resolves the hour either side of a fall-back transition", () => { + // 2026-11-01, US DST ends at 02:00 local. 01:00 local occurs twice; the hour before and + // the hour after must still land on distinct, correctly-ordered instants. + const before = dateHourToUtcMs("2026110100", "America/Los_Angeles")! + const after = dateHourToUtcMs("2026110103", "America/Los_Angeles")! + expect(iso(before)).toBe("2026-11-01T07:00:00.000Z") + expect(iso(after)).toBe("2026-11-01T11:00:00.000Z") + expect(after).toBeGreaterThan(before) + }) + + it("handles a half-hour offset zone", () => { + // Asia/Kolkata is UTC+5:30 — the case that catches an implementation assuming whole hours. + expect(iso(dateHourToUtcMs("2026090914", "Asia/Kolkata")!)).toBe("2026-09-09T08:30:00.000Z") + }) + + it("handles midnight without rolling the day", () => { + expect(iso(dateHourToUtcMs("2026090900", "UTC")!)).toBe("2026-09-09T00:00:00.000Z") + expect(iso(dateHourToUtcMs("2026090900", "Asia/Tokyo")!)).toBe("2026-09-08T15:00:00.000Z") + }) + + it("returns null rather than guessing at malformed input", () => { + expect(dateHourToUtcMs("", "UTC")).toBeNull() + expect(dateHourToUtcMs("20260909", "UTC")).toBeNull() + expect(dateHourToUtcMs("2026090924", "UTC")).toBeNull() + expect(dateHourToUtcMs("2026139914", "UTC")).toBeNull() + expect(dateHourToUtcMs("(other)", "UTC")).toBeNull() + }) + + it("returns null for a timezone the runtime does not know", () => { + expect(dateHourToUtcMs("2026090914", "Mars/Olympus_Mons")).toBeNull() + }) +}) + +describe("utcMsToZonedDate", () => { + it("renders the property-local date, not the UTC one", () => { + const instant = Date.parse("2026-09-09T02:00:00.000Z") + expect(utcMsToZonedDate(instant, "UTC")).toBe("2026-09-09") + // 02:00 UTC is still the previous evening in Los Angeles — asking GA4 for the UTC date + // would miss a whole local day at the window edge. + expect(utcMsToZonedDate(instant, "America/Los_Angeles")).toBe("2026-09-08") + expect(utcMsToZonedDate(instant, "Asia/Tokyo")).toBe("2026-09-09") + }) + + it("returns null for an unknown timezone", () => { + expect(utcMsToZonedDate(Date.now(), "Mars/Olympus_Mons")).toBeNull() + }) +}) diff --git a/apps/api/src/services/integrations/google-analytics/timezone.ts b/apps/api/src/services/integrations/google-analytics/timezone.ts new file mode 100644 index 000000000..c7381a395 --- /dev/null +++ b/apps/api/src/services/integrations/google-analytics/timezone.ts @@ -0,0 +1,101 @@ +/** + * GA4 `dateHour` → UTC epoch milliseconds. + * + * The Data API expresses `dateHour` ("YYYYMMDDHH") in the PROPERTY'S configured reporting + * timezone, not UTC, and says so nowhere in the response. A property set to + * `America/Los_Angeles` reporting hour `2026090914` means 14:00 Pacific — 21:00 or 22:00 UTC + * depending on the date. Treating the string as UTC would shift every bucket by a whole number + * of hours, consistently and invisibly: the chart would look plausible and be wrong, and the + * reconciliation ledger would happily keep it consistent with itself. + * + * The conversion is done against the platform's own tz database via `Intl` rather than a date + * library, so it tracks DST rule changes without a dependency to keep current. + */ + +/** Cached per zone — constructing an `Intl.DateTimeFormat` is expensive and this runs per row. */ +const formatters = new Map() + +const formatterFor = (timeZone: string): Intl.DateTimeFormat | null => { + const cached = formatters.get(timeZone) + if (cached !== undefined) return cached + try { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + // h23, not hour12:false — the latter renders midnight as "24" on some engines, which + // would push every midnight bucket a day forward. + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + formatters.set(timeZone, formatter) + return formatter + } catch { + // An unknown IANA zone (a GA4 property configured with something this runtime's tz + // database has never heard of). Reported as null so the caller skips the property rather + // than silently filing its data under the wrong hour. + return null + } +} + +/** Offset in ms of `timeZone` at a given UTC instant: (wall-clock time there) − (UTC time). */ +const offsetAt = (formatter: Intl.DateTimeFormat, utcMs: number): number => { + const parts = formatter.formatToParts(new Date(utcMs)) + const lookup = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((candidate) => candidate.type === type) + return part === undefined ? 0 : Number(part.value) + } + const asIfUtc = Date.UTC( + lookup("year"), + lookup("month") - 1, + lookup("day"), + lookup("hour"), + lookup("minute"), + lookup("second"), + ) + return asIfUtc - utcMs +} + +/** + * Convert a GA4 `dateHour` in `timeZone` to the UTC epoch ms of that hour's start. + * + * Returns null for a malformed `dateHour` or an unknown timezone — both mean "we cannot place + * this row on the timeline", and a skipped row is strictly better than a misplaced one. + */ +export const dateHourToUtcMs = (dateHour: string, timeZone: string): number | null => { + if (!/^\d{10}$/.test(dateHour)) return null + const year = Number(dateHour.slice(0, 4)) + const month = Number(dateHour.slice(4, 6)) + const day = Number(dateHour.slice(6, 8)) + const hour = Number(dateHour.slice(8, 10)) + if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null + + const formatter = formatterFor(timeZone) + if (formatter === null) return null + + // Solve `localWallClock(utc) == target` by fixed point. The first guess uses the offset at the + // naive instant; one refinement settles it, because a second application lands on the correct + // side of any DST transition (offsets change by at most a couple of hours, far less than the + // day-wide window the first guess is already inside). + const target = Date.UTC(year, month - 1, day, hour) + const firstGuess = target - offsetAt(formatter, target) + const refined = target - offsetAt(formatter, firstGuess) + return refined +} + +/** UTC epoch ms → the `YYYY-MM-DD` the Data API wants for a date range, in `timeZone`. */ +export const utcMsToZonedDate = (utcMs: number, timeZone: string): string | null => { + const formatter = formatterFor(timeZone) + if (formatter === null) return null + const parts = formatter.formatToParts(new Date(utcMs)) + const value = (type: Intl.DateTimeFormatPartTypes): string => + parts.find((candidate) => candidate.type === type)?.value ?? "" + const year = value("year") + const month = value("month") + const day = value("day") + if (year === "" || month === "" || day === "") return null + return `${year}-${month}-${day}` +} diff --git a/apps/api/src/services/integrations/shared/cardinality.ts b/apps/api/src/services/integrations/shared/cardinality.ts new file mode 100644 index 000000000..edfb76762 --- /dev/null +++ b/apps/api/src/services/integrations/shared/cardinality.ts @@ -0,0 +1,29 @@ +/** + * Attribute-cardinality folding for integration pollers. + * + * Every provider hands back at least one unbounded dimension — hostnames, WAF rule ids, DNS + * query names, GA4 page paths — and `Attributes` sits in the metrics tables' sorting key, so an + * uncapped dimension bloats it and degrades every read of that metric, not just the breakdown. + * The fix is the same everywhere: keep the N heaviest values across the window and fold the tail + * into one explicit {@link OTHER_BUCKET} series, so the total still reconciles. + * + * Shared by the Cloudflare edge-analytics mapper and the Google Analytics collector. + */ + +/** The folded tail. One series, so a breakdown's parts still sum to the unbroken total. */ +export const OTHER_BUCKET = "other" + +/** Top-N keys by weight; ties break lexicographically so folding is deterministic across runs. */ +export const topNKeys = (weights: ReadonlyMap, n: number): ReadonlySet => + new Set( + [...weights.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, n) + .map(([key]) => key), + ) + +/** Weight-rank an unbounded dimension across a window and fold the tail into {@link OTHER_BUCKET}. */ +export const foldTail = (weights: ReadonlyMap, n: number): ((key: string) => string) => { + const top = topNKeys(weights, n) + return (key) => (top.has(key) ? key : OTHER_BUCKET) +} diff --git a/apps/api/src/services/integrations/cloudflare-analytics/otlp.test.ts b/apps/api/src/services/integrations/shared/otlp.test.ts similarity index 100% rename from apps/api/src/services/integrations/cloudflare-analytics/otlp.test.ts rename to apps/api/src/services/integrations/shared/otlp.test.ts diff --git a/apps/api/src/services/integrations/cloudflare-analytics/otlp.ts b/apps/api/src/services/integrations/shared/otlp.ts similarity index 89% rename from apps/api/src/services/integrations/cloudflare-analytics/otlp.ts rename to apps/api/src/services/integrations/shared/otlp.ts index 63f4f4c02..a1ceba927 100644 --- a/apps/api/src/services/integrations/cloudflare-analytics/otlp.ts +++ b/apps/api/src/services/integrations/shared/otlp.ts @@ -1,12 +1,15 @@ /** * Encode collector-shaped metric rows (`MetricSumRow` / `MetricGaugeRow`) into an OTLP/HTTP JSON - * `ExportMetricsServiceRequest`, so the Cloudflare poller can ship its synthetic edge metrics - * through the ingest gateway (`POST /v1/metrics`) exactly like every other telemetry source — - * which is what gives per-org routing (managed Tinybird vs BYO ClickHouse), schema-version gating, - * WAL durability, and Autumn metering for free. The rows are already OTel-metric-shaped, so this is + * `ExportMetricsServiceRequest`, so an integration poller can ship its synthetic metrics through + * the ingest gateway (`POST /v1/metrics`) exactly like every other telemetry source — which is + * what gives per-org routing (managed Tinybird vs BYO ClickHouse), schema-version gating, WAL + * durability, and Autumn metering for free. The rows are already OTel-metric-shaped, so this is * a mechanical re-expression: no query paths change (the downstream collector still lands them in * `metrics_sum` / `metrics_gauge`). * + * Provider-agnostic on purpose — the Cloudflare edge-analytics poller and the Google Analytics + * collector both emit through it, and any future polling integration should too. + * * Rows are grouped resource → scope → metric to match the OTLP envelope: sum rows become `sum` * metrics (carrying the row's `aggregation_temporality` — 1 = DELTA — and `is_monotonic`), gauge * rows become bare `gauge` metrics. Timestamps are the row's ClickHouse DateTime64 literal @@ -106,7 +109,7 @@ const scopeKey = (row: MetricGaugeRow): string => `${row.scope_schema_url}\x00${row.scope_name}\x00${row.scope_version}\x00${stableJson(row.scope_attributes)}` /** - * Convert Cloudflare metric rows into a single OTLP/JSON metrics request. Sum and gauge metrics + * Convert integration metric rows into a single OTLP/JSON metrics request. Sum and gauge metrics * coexist in the same envelope; the downstream collector fans them back out to * `metrics_sum` / `metrics_gauge`. */ diff --git a/packages/db/drizzle/0055_google_analytics_integration.sql b/packages/db/drizzle/0055_google_analytics_integration.sql new file mode 100644 index 000000000..74f86b2ab --- /dev/null +++ b/packages/db/drizzle/0055_google_analytics_integration.sql @@ -0,0 +1,36 @@ +CREATE TABLE "google_analytics_ledger" ( + "id" text PRIMARY KEY NOT NULL, + "org_id" text NOT NULL, + "property_id" text NOT NULL, + "dataset" text NOT NULL, + "bucket_at" timestamp with time zone NOT NULL, + "emitted_json" text NOT NULL, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE TABLE "google_analytics_state" ( + "id" text PRIMARY KEY NOT NULL, + "org_id" text NOT NULL, + "property_id" text DEFAULT '' NOT NULL, + "property_name" text, + "account_name" text, + "time_zone" text, + "dataset" text NOT NULL, + "enabled" boolean DEFAULT true NOT NULL, + "watermark_at" timestamp with time zone, + "backfill_at" timestamp with time zone, + "frozen_through_at" timestamp with time zone, + "discovered_at" timestamp with time zone, + "last_success_at" timestamp with time zone, + "last_error" text, + "last_error_at" timestamp with time zone, + "lease_until" timestamp with time zone, + "created_at" timestamp with time zone NOT NULL, + "updated_at" timestamp with time zone NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "ga_ledger_org_property_dataset_bucket_idx" ON "google_analytics_ledger" USING btree ("org_id","property_id","dataset","bucket_at");--> statement-breakpoint +CREATE INDEX "ga_ledger_org_bucket_idx" ON "google_analytics_ledger" USING btree ("org_id","bucket_at");--> statement-breakpoint +CREATE UNIQUE INDEX "ga_analytics_state_org_property_dataset_idx" ON "google_analytics_state" USING btree ("org_id","property_id","dataset");--> statement-breakpoint +CREATE INDEX "ga_analytics_state_org_idx" ON "google_analytics_state" USING btree ("org_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0055_snapshot.json b/packages/db/drizzle/meta/0055_snapshot.json new file mode 100644 index 000000000..9f5e0731a --- /dev/null +++ b/packages/db/drizzle/meta/0055_snapshot.json @@ -0,0 +1,9105 @@ +{ + "id": "bdf5970d-9f21-4c09-b130-7ac54c3379c9", + "prevId": "08d43f4b-2c3a-4173-8e8e-1999ae66b5d0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_triage_settings": { + "name": "ai_triage_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_runs_per_day": { + "name": "max_runs_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "max_passes_per_day": { + "name": "max_passes_per_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 90 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_delivery_events": { + "name": "alert_delivery_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_message": { + "name": "provider_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_delivery_events_org_idx": { + "name": "alert_delivery_events_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_org_incident_idx": { + "name": "alert_delivery_events_org_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_due_idx": { + "name": "alert_delivery_events_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_claim_idx": { + "name": "alert_delivery_events_claim_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_delivery_events_delivery_attempt_idx": { + "name": "alert_delivery_events_delivery_attempt_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_destinations": { + "name": "alert_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_tested_at": { + "name": "last_tested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_error": { + "name": "last_test_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_destinations_org_idx": { + "name": "alert_destinations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_enabled_idx": { + "name": "alert_destinations_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_destinations_org_name_idx": { + "name": "alert_destinations_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_incidents": { + "name": "alert_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_key": { + "name": "incident_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_name": { + "name": "rule_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_delivered_event_type": { + "name": "last_delivered_event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_incidents_org_idx": { + "name": "alert_incidents_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_status_idx": { + "name": "alert_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_rule_idx": { + "name": "alert_incidents_org_rule_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_org_issue_idx": { + "name": "alert_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_incident_key_idx": { + "name": "alert_incidents_incident_key_idx", + "columns": [ + { + "expression": "incident_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_incidents_open_group_idx": { + "name": "alert_incidents_open_group_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"alert_incidents\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_claims": { + "name": "alert_rule_claims", + "schema": "", + "columns": { + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_claims_org_idx": { + "name": "alert_rule_claims_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rule_states": { + "name": "alert_rule_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rule_id": { + "name": "rule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_key": { + "name": "group_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'__total__'" + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rule_states_org_idx": { + "name": "alert_rule_states_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "alert_rule_states_org_id_rule_id_group_key_pk": { + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "columns": [ + "org_id", + "rule_id", + "group_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_rules": { + "name": "alert_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notification_template_json": { + "name": "notification_template_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_names_json": { + "name": "service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exclude_service_names_json": { + "name": "exclude_service_names_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "environments_json": { + "name": "environments_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tags_json": { + "name": "tags_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comparator": { + "name": "comparator", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "threshold": { + "name": "threshold", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_upper": { + "name": "threshold_upper", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "window_minutes": { + "name": "window_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "minimum_sample_count": { + "name": "minimum_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_breaches_required": { + "name": "consecutive_breaches_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "consecutive_healthy_required": { + "name": "consecutive_healthy_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "renotify_interval_minutes": { + "name": "renotify_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "apdex_threshold_ms": { + "name": "apdex_threshold_ms", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "query_builder_draft_json": { + "name": "query_builder_draft_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_query_sql": { + "name": "raw_query_sql", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_by": { + "name": "group_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "query_spec_json": { + "name": "query_spec_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reducer": { + "name": "reducer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sample_count_strategy": { + "name": "sample_count_strategy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "no_data_behavior": { + "name": "no_data_behavior", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_scheduled_at": { + "name": "last_scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "alert_rules_org_idx": { + "name": "alert_rules_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_enabled_idx": { + "name": "alert_rules_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "alert_rules_org_name_idx": { + "name": "alert_rules_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_settings": { + "name": "anomaly_detector_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sensitivity": { + "name": "sensitivity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "muted_signals_json": { + "name": "muted_signals_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_tick_at": { + "name": "last_tick_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_detector_states": { + "name": "anomaly_detector_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_breaches": { + "name": "consecutive_breaches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consecutive_healthy": { + "name": "consecutive_healthy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_status": { + "name": "last_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_incident_id": { + "name": "last_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_detector_states_open_incident_idx": { + "name": "anomaly_detector_states_open_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"anomaly_detector_states\".\"open_incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_detector_states_evaluated_idx": { + "name": "anomaly_detector_states_evaluated_idx", + "columns": [ + { + "expression": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "anomaly_detector_states_org_id_detector_key_pk": { + "name": "anomaly_detector_states_org_id_detector_key_pk", + "columns": [ + "org_id", + "detector_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anomaly_incidents": { + "name": "anomaly_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detector_key": { + "name": "detector_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_env": { + "name": "deployment_env", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_issue_id": { + "name": "error_issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opened_value": { + "name": "opened_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_median": { + "name": "baseline_median", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "baseline_sigma": { + "name": "baseline_sigma", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "threshold_value": { + "name": "threshold_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_observed_value": { + "name": "last_observed_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "last_sample_count": { + "name": "last_sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolve_reason": { + "name": "resolve_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprints_json": { + "name": "fingerprints_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "reopen_count": { + "name": "reopen_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reopened_at": { + "name": "last_reopened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "anomaly_incidents_org_status_triggered_idx": { + "name": "anomaly_incidents_org_status_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_triggered_idx": { + "name": "anomaly_incidents_org_triggered_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_detector_idx": { + "name": "anomaly_incidents_org_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_org_issue_idx": { + "name": "anomaly_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "error_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "anomaly_incidents_open_detector_idx": { + "name": "anomaly_incidents_open_detector_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "detector_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"anomaly_incidents\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_email": { + "name": "created_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_org_id_idx": { + "name": "api_keys_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_analytics_state": { + "name": "cloudflare_analytics_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_at": { + "name": "backfill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_fetched_at": { + "name": "settings_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quantiles_available": { + "name": "quantiles_available", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "live_scripts_json": { + "name": "live_scripts_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cf_analytics_state_org_account_dataset_zone_idx": { + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cf_analytics_state_org_idx": { + "name": "cf_analytics_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_hyperdrive_configs": { + "name": "cloudflare_hyperdrive_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_host": { + "name": "origin_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_port": { + "name": "origin_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "origin_scheme": { + "name": "origin_scheme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_database": { + "name": "origin_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_user": { + "name": "origin_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_hyperdrive_configs_org_config_idx": { + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_hyperdrive_configs_org_idx": { + "name": "cloudflare_hyperdrive_configs_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloudflare_logpush_connectors": { + "name": "cloudflare_logpush_connectors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'http_requests'" + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_tag": { + "name": "secret_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_received_at": { + "name": "last_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_rotated_at": { + "name": "secret_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cloudflare_logpush_connectors_org_idx": { + "name": "cloudflare_logpush_connectors_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_org_enabled_idx": { + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cloudflare_logpush_connectors_secret_hash_unique": { + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_device_authorizations": { + "name": "cli_device_authorizations", + "schema": "", + "columns": { + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_code_hash": { + "name": "user_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_org_id": { + "name": "approved_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_user_id": { + "name": "approved_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_roles": { + "name": "approved_roles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_user_email": { + "name": "approved_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_tag": { + "name": "token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "denied_at": { + "name": "denied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cli_device_authorizations_user_code_unique": { + "name": "cli_device_authorizations_user_code_unique", + "columns": [ + { + "expression": "user_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_device_authorizations_expires_idx": { + "name": "cli_device_authorizations_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_authorizations": { + "name": "mcp_oauth_authorizations", + "schema": "", + "columns": { + "request_id_hash": { + "name": "request_id_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_code_hash": { + "name": "authorization_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_org_id": { + "name": "approved_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_user_id": { + "name": "approved_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_roles": { + "name": "approved_roles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_user_email": { + "name": "approved_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "denied_at": { + "name": "denied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_authorizations_code_unique": { + "name": "mcp_oauth_authorizations_code_unique", + "columns": [ + { + "expression": "authorization_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_authorizations_expires_idx": { + "name": "mcp_oauth_authorizations_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_clients": { + "name": "mcp_oauth_clients", + "schema": "", + "columns": { + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "client_uri": { + "name": "client_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_refresh_tokens": { + "name": "mcp_oauth_refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roles": { + "name": "roles", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_key_id": { + "name": "access_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "family_expires_at": { + "name": "family_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "mcp_oauth_refresh_tokens_hash_unique": { + "name": "mcp_oauth_refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_refresh_tokens_family_idx": { + "name": "mcp_oauth_refresh_tokens_family_idx", + "columns": [ + { + "expression": "family_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_refresh_tokens_expires_idx": { + "name": "mcp_oauth_refresh_tokens_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_devices": { + "name": "mobile_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bundle_id": { + "name": "bundle_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "live_activity_start_token": { + "name": "live_activity_start_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preferences": { + "name": "preferences", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_pushed_at": { + "name": "last_pushed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mobile_devices_org_platform_token_unique": { + "name": "mobile_devices_org_platform_token_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_devices_org_idx": { + "name": "mobile_devices_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mobile_devices_user_idx": { + "name": "mobile_devices_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_shares": { + "name": "dashboard_shares", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "widget_id": { + "name": "widget_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_tag": { + "name": "token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dashboard_shares_token_hash_unq": { + "name": "dashboard_shares_token_hash_unq", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_live_unq": { + "name": "dashboard_shares_live_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(widget_id, '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "revoked_at is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_org_dashboard_idx": { + "name": "dashboard_shares_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_shares_id_idx": { + "name": "dashboard_shares_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dashboard_shares_dashboard_fk": { + "name": "dashboard_shares_dashboard_fk", + "tableFrom": "dashboard_shares", + "tableTo": "dashboards", + "columnsFrom": [ + "org_id", + "dashboard_id" + ], + "columnsTo": [ + "org_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "dashboard_shares_org_id_id_pk": { + "name": "dashboard_shares_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboard_versions": { + "name": "dashboard_versions", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "change_kind": { + "name": "change_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_version_id": { + "name": "source_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "dashboard_versions_org_dashboard_idx": { + "name": "dashboard_versions_org_dashboard_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboard_versions_org_dashboard_version_unq": { + "name": "dashboard_versions_org_dashboard_version_unq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dashboard_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboard_versions_org_id_id_pk": { + "name": "dashboard_versions_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dashboards": { + "name": "dashboards", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "dashboards_org_updated_idx": { + "name": "dashboards_org_updated_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dashboards_org_name_idx": { + "name": "dashboards_org_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "dashboards_org_id_id_pk": { + "name": "dashboards_org_id_id_pk", + "columns": [ + "org_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.digest_subscriptions": { + "name": "digest_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "opted_out_at": { + "name": "opted_out_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "namespaces_json": { + "name": "namespaces_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "environments_json": { + "name": "environments_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_sent_at": { + "name": "last_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "digest_subscriptions_org_user_idx": { + "name": "digest_subscriptions_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "digest_subscriptions_org_enabled_idx": { + "name": "digest_subscriptions_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "actors_org_user_idx": { + "name": "actors_org_user_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_agent_name_idx": { + "name": "actors_org_agent_name_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "actors_org_type_idx": { + "name": "actors_org_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_fingerprint_candidates": { + "name": "error_fingerprint_candidates", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_versions_json": { + "name": "service_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_fingerprint_candidates_last_seen_idx": { + "name": "error_fingerprint_candidates_last_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_fingerprint_candidates_org_id_fingerprint_hash_pk": { + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "columns": [ + "org_id", + "fingerprint_hash" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_incidents": { + "name": "error_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_triggered_at": { + "name": "first_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_incidents_org_issue_idx": { + "name": "error_incidents_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_incidents_org_status_idx": { + "name": "error_incidents_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_triggered_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_events": { + "name": "error_issue_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_events_issue_idx": { + "name": "error_issue_events_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_actor_idx": { + "name": "error_issue_events_actor_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_events_type_idx": { + "name": "error_issue_events_type_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_pull_requests": { + "name": "error_issue_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "merge_commit_sha": { + "name": "merge_commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_source": { + "name": "link_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_by_actor_id": { + "name": "linked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_pull_requests_issue_pr_idx": { + "name": "error_issue_pull_requests_issue_pr_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_pull_requests_repo_number_idx": { + "name": "error_issue_pull_requests_repo_number_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_pull_requests_issue_idx": { + "name": "error_issue_pull_requests_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_states": { + "name": "error_issue_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_observed_occurrence_at": { + "name": "last_observed_occurrence_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "open_incident_id": { + "name": "open_incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "error_issue_states_org_id_issue_id_pk": { + "name": "error_issue_states_org_id_issue_id_pk", + "columns": [ + "org_id", + "issue_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issue_verifications": { + "name": "error_issue_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "verify_after": { + "name": "verify_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "baseline_versions_json": { + "name": "baseline_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "baseline_occurrence_count": { + "name": "baseline_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "baseline_rate_per_hour": { + "name": "baseline_rate_per_hour", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_note": { + "name": "verdict_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "post_merge_occurrence_count": { + "name": "post_merge_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issue_verifications_due_idx": { + "name": "error_issue_verifications_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verify_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_verifications_issue_idx": { + "name": "error_issue_verifications_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issue_verifications_open_idx": { + "name": "error_issue_verifications_open_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"error_issue_verifications\".\"status\" in ('waiting', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_issues": { + "name": "error_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'error'" + }, + "source_ref_json": { + "name": "source_ref_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fingerprint_hash": { + "name": "fingerprint_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fingerprint_version": { + "name": "fingerprint_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_type": { + "name": "exception_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exception_message": { + "name": "exception_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_label": { + "name": "error_label", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "top_frame": { + "name": "top_frame", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_state": { + "name": "workflow_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'triage'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "severity_source": { + "name": "severity_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_actor_id": { + "name": "assigned_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_holder_actor_id": { + "name": "lease_holder_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolved_by_actor_id": { + "name": "resolved_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_resolved_at": { + "name": "last_resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_regressed_at": { + "name": "last_regressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "regression_count": { + "name": "regression_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "seen_versions_json": { + "name": "seen_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "resolved_versions_json": { + "name": "resolved_versions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "snooze_until": { + "name": "snooze_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_issues_org_fp_idx": { + "name": "error_issues_org_fp_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_workflow_idx": { + "name": "error_issues_org_workflow_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_severity_idx": { + "name": "error_issues_org_severity_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_live_seen_idx": { + "name": "error_issues_org_live_seen_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"error_issues\".\"archived_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_fp_version_idx": { + "name": "error_issues_org_fp_version_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_assignee_idx": { + "name": "error_issues_org_assignee_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_lease_expiry_idx": { + "name": "error_issues_lease_expiry_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_issues_org_archived_idx": { + "name": "error_issues_org_archived_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"error_issues\".\"archived_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_deliveries": { + "name": "error_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_key": { + "name": "delivery_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_notification_deliveries_due_idx": { + "name": "error_notification_deliveries_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_notification_deliveries_org_idx": { + "name": "error_notification_deliveries_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "error_notification_deliveries_key_destination_idx": { + "name": "error_notification_deliveries_key_destination_idx", + "columns": [ + { + "expression": "delivery_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_notification_policies": { + "name": "error_notification_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "destination_ids_json": { + "name": "destination_ids_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "notify_on_first_seen": { + "name": "notify_on_first_seen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_regression": { + "name": "notify_on_regression", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_on_resolve": { + "name": "notify_on_resolve", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_in_review": { + "name": "notify_on_transition_in_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_transition_done": { + "name": "notify_on_transition_done", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notify_on_claim": { + "name": "notify_on_claim", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "min_occurrence_count": { + "name": "min_occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'warning'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_tick_states": { + "name": "error_tick_states", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "processed_through": { + "name": "processed_through", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "bootstrap_completed": { + "name": "bootstrap_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "error_tick_states_claim_idx": { + "name": "error_tick_states_claim_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalation_policies": { + "name": "issue_escalation_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rules_json": { + "name": "rules_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_escalations": { + "name": "issue_escalations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "delivery_results_json": { + "name": "delivery_results_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "issue_escalations_dedupe_idx": { + "name": "issue_escalations_dedupe_idx", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_due_idx": { + "name": "issue_escalations_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_escalations_org_issue_idx": { + "name": "issue_escalations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_analytics_ledger": { + "name": "google_analytics_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket_at": { + "name": "bucket_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "emitted_json": { + "name": "emitted_json", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ga_ledger_org_property_dataset_bucket_idx": { + "name": "ga_ledger_org_property_dataset_bucket_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ga_ledger_org_bucket_idx": { + "name": "ga_ledger_org_bucket_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "bucket_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.google_analytics_state": { + "name": "google_analytics_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "property_id": { + "name": "property_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "property_name": { + "name": "property_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_name": { + "name": "account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_at": { + "name": "backfill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "frozen_through_at": { + "name": "frozen_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ga_analytics_state_org_property_dataset_idx": { + "name": "ga_analytics_state_org_property_dataset_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "property_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ga_analytics_state_org_idx": { + "name": "ga_analytics_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigation_lens_runs": { + "name": "investigation_lens_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "investigation_id": { + "name": "investigation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lens_id": { + "name": "lens_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claim": { + "name": "claim", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "progress_note": { + "name": "progress_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elapsed_ms": { + "name": "elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lens_name": { + "name": "lens_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lens_question": { + "name": "lens_question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deadline_hit": { + "name": "deadline_hit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hypothesis_json": { + "name": "hypothesis_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "evidence_json": { + "name": "evidence_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mechanism": { + "name": "mechanism", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "self_doubt": { + "name": "self_doubt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suggested_actions_json": { + "name": "suggested_actions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ranked_at": { + "name": "ranked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigation_lens_runs_lens_idx": { + "name": "investigation_lens_runs_lens_idx", + "columns": [ + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lens_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigation_lens_runs_org_inv_idx": { + "name": "investigation_lens_runs_org_inv_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "investigation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "investigation_lens_runs_investigation_id_investigations_id_fk": { + "name": "investigation_lens_runs_investigation_id_investigations_id_fk", + "tableFrom": "investigation_lens_runs", + "tableTo": "investigations", + "columnsFrom": [ + "investigation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.investigations": { + "name": "investigations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'investigating'" + }, + "seeded_by": { + "name": "seeded_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "subject_json": { + "name": "subject_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "incident_kind": { + "name": "incident_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "report_json": { + "name": "report_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fanout_state": { + "name": "fanout_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "fanout_size": { + "name": "fanout_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "plan_json": { + "name": "plan_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "planner_model": { + "name": "planner_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planner_elapsed_ms": { + "name": "planner_elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "validator_note": { + "name": "validator_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validator_elapsed_ms": { + "name": "validator_elapsed_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fanout_deadline_at": { + "name": "fanout_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fanout_attempt": { + "name": "fanout_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "autonomous_turns": { + "name": "autonomous_turns", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "diagnosed_at": { + "name": "diagnosed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "investigations_incident_idx": { + "name": "investigations_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"investigations\".\"incident_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_created_idx": { + "name": "investigations_org_created_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_issue_idx": { + "name": "investigations_org_issue_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "investigations_org_status_idx": { + "name": "investigations_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.live_activities": { + "name": "live_activities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "incident_id": { + "name": "incident_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "activity_id": { + "name": "activity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_reason": { + "name": "ended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "live_activities_device_incident_unique": { + "name": "live_activities_device_incident_unique", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "live_activities_incident_idx": { + "name": "live_activities_incident_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "incident_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_auth_states": { + "name": "oauth_auth_states", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_auth_states_expires_idx": { + "name": "oauth_auth_states_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_connections": { + "name": "oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_id": { + "name": "external_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_user_email": { + "name": "external_user_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_account_name": { + "name": "external_account_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_accounts_json": { + "name": "granted_accounts_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_iv": { + "name": "refresh_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_tag": { + "name": "refresh_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_connections_org_provider_idx": { + "name": "oauth_connections_org_provider_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_connections_org_idx": { + "name": "oauth_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_onboarding_state": { + "name": "org_onboarding_state", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "demo_data_requested": { + "name": "demo_data_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "checklist_dismissed_at": { + "name": "checklist_dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_data_received_at": { + "name": "first_data_received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "welcome_email_sent_at": { + "name": "welcome_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connect_nudge_email_sent_at": { + "name": "connect_nudge_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stalled_email_sent_at": { + "name": "stalled_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activation_email_sent_at": { + "name": "activation_email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_attribute_mappings": { + "name": "org_ingest_attribute_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_ingest_attribute_mappings_org_idx": { + "name": "org_ingest_attribute_mappings_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_recommendation_issues": { + "name": "org_recommendation_issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_key": { + "name": "canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "org_recommendation_issues_org_idx": { + "name": "org_recommendation_issues_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_recommendation_issues_org_key_idx": { + "name": "org_recommendation_issues_org_key_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommendation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_keys": { + "name": "org_ingest_keys", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_hash": { + "name": "public_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_ciphertext": { + "name": "private_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_tag": { + "name": "private_key_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_hash": { + "name": "private_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_rotated_at": { + "name": "public_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "private_rotated_at": { + "name": "private_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "org_ingest_keys_public_key_unique": { + "name": "org_ingest_keys_public_key_unique", + "columns": [ + { + "expression": "public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_public_key_hash_unique": { + "name": "org_ingest_keys_public_key_hash_unique", + "columns": [ + { + "expression": "public_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_ingest_keys_private_key_hash_unique": { + "name": "org_ingest_keys_private_key_hash_unique", + "columns": [ + { + "expression": "private_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_ingest_keys_org_id_pk": { + "name": "org_ingest_keys_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_ingest_sampling_policies": { + "name": "org_ingest_sampling_policies", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "trace_sample_ratio": { + "name": "trace_sample_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "always_keep_error_spans": { + "name": "always_keep_error_spans", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "always_keep_slow_spans_ms": { + "name": "always_keep_slow_spans_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_settings": { + "name": "org_clickhouse_settings", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_url": { + "name": "ch_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_user": { + "name": "ch_user", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ch_password_ciphertext": { + "name": "ch_password_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_iv": { + "name": "ch_password_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_password_tag": { + "name": "ch_password_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ch_database": { + "name": "ch_database", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_settings_org_id_pk": { + "name": "org_clickhouse_settings_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_clickhouse_schema_apply_runs": { + "name": "org_clickhouse_schema_apply_runs", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_instance_id": { + "name": "workflow_instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_migration": { + "name": "current_migration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_total": { + "name": "steps_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steps_done": { + "name": "steps_done", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "applied_versions": { + "name": "applied_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "skipped": { + "name": "skipped", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "org_clickhouse_schema_apply_runs_org_id_pk": { + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "columns": [ + "org_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_connections": { + "name": "planetscale_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ps_organization": { + "name": "ps_organization", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scrape_target_id": { + "name": "scrape_target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_ciphertext": { + "name": "webhook_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_iv": { + "name": "webhook_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_secret_tag": { + "name": "webhook_secret_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_permissions_json": { + "name": "detected_permissions_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_inventory_at": { + "name": "last_inventory_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_inventory_error": { + "name": "last_inventory_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_connections_org_idx": { + "name": "planetscale_connections_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_databases": { + "name": "planetscale_databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mysql'" + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branches_json": { + "name": "branches_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_databases_org_db_idx": { + "name": "planetscale_databases_org_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_databases_org_idx": { + "name": "planetscale_databases_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_events": { + "name": "planetscale_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "database_name": { + "name": "database_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_login": { + "name": "actor_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_events_dedupe_idx": { + "name": "planetscale_events_dedupe_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_events_org_db_time_idx": { + "name": "planetscale_events_org_db_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_events_org_time_idx": { + "name": "planetscale_events_org_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planetscale_poll_state": { + "name": "planetscale_poll_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dataset": { + "name": "dataset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "watermark_at": { + "name": "watermark_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_until": { + "name": "lease_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "planetscale_poll_state_org_dataset_db_idx": { + "name": "planetscale_poll_state_org_dataset_db_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dataset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "database_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planetscale_poll_state_org_idx": { + "name": "planetscale_poll_state_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_target_checks": { + "name": "scrape_target_checks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_target_key": { + "name": "sub_target_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_scraped": { + "name": "samples_scraped", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "samples_post_relabel": { + "name": "samples_post_relabel", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scrape_target_checks_target_checked_idx": { + "name": "scrape_target_checks_target_checked_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scrape_target_checks_target_id_scrape_targets_id_fk": { + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "tableFrom": "scrape_target_checks", + "tableTo": "scrape_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scrape_targets": { + "name": "scrape_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'prometheus'" + }, + "discovery_config_json": { + "name": "discovery_config_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scrape_interval_seconds": { + "name": "scrape_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "labels_json": { + "name": "labels_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_ciphertext": { + "name": "auth_credentials_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_iv": { + "name": "auth_credentials_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_credentials_tag": { + "name": "auth_credentials_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_scrape_at": { + "name": "last_scrape_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_scrape_error": { + "name": "last_scrape_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scrape_targets_org_idx": { + "name": "scrape_targets_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scrape_targets_org_enabled_idx": { + "name": "scrape_targets_org_enabled_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slack_workspaces": { + "name": "slack_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_name": { + "name": "team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_ciphertext": { + "name": "bot_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_iv": { + "name": "bot_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bot_token_tag": { + "name": "bot_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_ciphertext": { + "name": "api_key_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_iv": { + "name": "api_key_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_secret_tag": { + "name": "api_key_secret_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "slack_workspaces_team_id_idx": { + "name": "slack_workspaces_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_workspaces_org_idx": { + "name": "slack_workspaces_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "slack_workspaces_active_org_idx": { + "name": "slack_workspaces_active_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"slack_workspaces\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_commits": { + "name": "vcs_commits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha": { + "name": "sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authored_at": { + "name": "authored_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "committed_at": { + "name": "committed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_commits_repo_sha_idx": { + "name": "vcs_commits_repo_sha_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_commits_org_sha_idx": { + "name": "vcs_commits_org_sha_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_installations": { + "name": "vcs_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_installation_id": { + "name": "external_installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_avatar_url": { + "name": "account_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "installed_by_user_id": { + "name": "installed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_installations_provider_external_idx": { + "name": "vcs_installations_provider_external_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_installations_org_idx": { + "name": "vcs_installations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repositories": { + "name": "vcs_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_repo_id": { + "name": "external_repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "tracked_branch": { + "name": "tracked_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "html_url": { + "name": "html_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repositories_org_repo_idx": { + "name": "vcs_repositories_org_repo_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_repo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_org_idx": { + "name": "vcs_repositories_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repositories_installation_idx": { + "name": "vcs_repositories_installation_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vcs_repository_branches": { + "name": "vcs_repository_branches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "vcs_repository_branches_repo_name_idx": { + "name": "vcs_repository_branches_repo_name_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "vcs_repository_branches_org_idx": { + "name": "vcs_repository_branches_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fd8ed4cf8..ea324d726 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1788285534887, "tag": "0054_alert_incident_open_uniqueness", "breakpoints": true + }, + { + "idx": 54, + "version": "7", + "when": 1788910743675, + "tag": "0055_google_analytics_integration", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/google-analytics-ledger.ts b/packages/db/src/schema/google-analytics-ledger.ts new file mode 100644 index 000000000..a0de1c694 --- /dev/null +++ b/packages/db/src/schema/google-analytics-ledger.ts @@ -0,0 +1,52 @@ +import type { OrgId } from "@maple/domain" +import { index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core" + +// Reconciliation ledger for the GA4 collector — the record of what has already been emitted to +// the warehouse, so a re-poll can emit the DIFFERENCE rather than a duplicate. +// +// WHY THIS EXISTS: `metrics_sum` is a plain MergeTree with no dedupe, and GA4 revises past hours +// for ~48h. Writing the re-polled value again would leave two rows at the same timestamp and +// every reducer would read wrong — `sum` double-counts, `avg` blends stale with fresh, `max` +// breaks on a downward revision. Instead each hour is emitted as a DELTA-temporality sum of +// `newValue - lastEmitted`, which makes `sum(Value)` per bucket exactly the current GA4 truth and +// turns a downward revision into a negative delta. +// +// SHAPE: one row per (org, property, dataset, bucket) holding a JSON map of seriesHash → last +// emitted value — NOT one row per series. A single property is then ~48h x 6 datasets = ~288 rows +// instead of ~9k; the per-series spelling would put ~930k rows on the PlanetScale primary at 100 +// properties, and we have already paid for main-branch bloat once. +// +// LIFETIME: rows are pruned once their bucket falls behind the owning state row's +// `frozenThroughAt` — the hour is final by then, so there is nothing left to reconcile against. +export const googleAnalyticsLedger = pgTable( + "google_analytics_ledger", + { + id: text("id").notNull().primaryKey(), + orgId: text("org_id").$type().notNull(), + propertyId: text("property_id").notNull(), + dataset: text("dataset").notNull(), + // START of the hour bucket these emissions belong to (UTC), matching the metric row's + // `TimeUnix`. GA4's `dateHour` dimension is hour-resolution, so this is always hour-aligned. + bucketAt: timestamp("bucket_at", { withTimezone: true, mode: "date" }).notNull(), + // JSON object: seriesHash → cumulative value already emitted for that series in this bucket. + // The series hash covers the metric name plus the sorted dimension tuple, so two series that + // differ only in, say, `country` never collide. Absent key = nothing emitted yet, so the + // first emission is the raw value. + emittedJson: text("emitted_json").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [ + uniqueIndex("ga_ledger_org_property_dataset_bucket_idx").on( + table.orgId, + table.propertyId, + table.dataset, + table.bucketAt, + ), + // Drives the prune sweep, which deletes by (org, property, dataset) below a bucket boundary. + index("ga_ledger_org_bucket_idx").on(table.orgId, table.bucketAt), + ], +) + +export type GoogleAnalyticsLedgerRow = typeof googleAnalyticsLedger.$inferSelect +export type GoogleAnalyticsLedgerInsert = typeof googleAnalyticsLedger.$inferInsert diff --git a/packages/db/src/schema/google-analytics-state.ts b/packages/db/src/schema/google-analytics-state.ts new file mode 100644 index 000000000..dcd6c4fcb --- /dev/null +++ b/packages/db/src/schema/google-analytics-state.ts @@ -0,0 +1,75 @@ +import type { OrgId } from "@maple/domain" +import { boolean, index, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core" + +// Poll-state for the GA4 Data API collector. One row per (org, property, dataset): every +// dataset in the DATASETS registry gets its own row per discovered property, so a dataset that +// falls behind (or that GA4 rejects for one property) does not hold up the others. +// +// The table doubles as the org's property cache. Property discovery (Admin API +// `accountSummaries.list`) reconciles rows on an hourly TTL, soft-disabling rows whose property +// disappeared so a re-appearing property resumes from its old watermark instead of re-backfilling. +// +// DISCOVERY ANCHOR: discovery is grant-wide, not per-property, so it cannot live on a property +// row (there may be none yet on a fresh connect). The anchor is the reserved row with +// propertyId = "" and dataset = "__discovery__" — it holds `discoveredAt` and nothing else, and +// is skipped by every poll pass. +export const googleAnalyticsState = pgTable( + "google_analytics_state", + { + id: text("id").notNull().primaryKey(), + orgId: text("org_id").$type().notNull(), + // Bare GA4 property id ("123456789"), not the API's "properties/123456789" resource name — + // the resource prefix is re-added at the call site so this stays usable as a label. + // "" only on the discovery anchor row. + propertyId: text("property_id").notNull().default(""), + // GA4 property display name, and the account summary it came from. Cached for the + // integration card and for the metric rows' resource attributes. + propertyName: text("property_name"), + accountName: text("account_name"), + // IANA reporting timezone of the property ("America/Los_Angeles"), from the Admin API's + // `properties.get`. Load-bearing, not decoration: GA4's `dateHour` dimension is expressed in + // THIS zone, so without it every bucket lands at the wrong UTC instant for any property not + // set to UTC — silently, and off by a whole number of hours. Fetched lazily once per + // property (accountSummaries does not carry it) and cached here; null means "not resolved + // yet", and the property is not polled until it is. + timeZone: text("time_zone"), + dataset: text("dataset").notNull(), + enabled: boolean("enabled").notNull().default(true), + // HEAD frontier: END of the newest hour bucket ingested. The poll fetches the newest window + // first so a freshly-connected property shows data within one tick. Null until the first poll. + watermarkAt: timestamp("watermark_at", { withTimezone: true, mode: "date" }), + // BACKFILL frontier: OLDEST hour boundary the history fill has reached, walking DOWN toward + // the backfill floor. Seeded to the first head window's start; complete once it hits the floor. + backfillAt: timestamp("backfill_at", { withTimezone: true, mode: "date" }), + // RESTATEMENT frontier, and the reason this table is not just Cloudflare's. GA4 keeps + // revising `dateHour` rows for ~48h after the fact, so an hour is only final once it falls + // behind this boundary: hours ending at or before it are frozen, never re-polled, and their + // reconciliation ledger rows are pruned. Everything between here and `watermarkAt` is + // re-polled each tick and emitted as a delta against the ledger. + frozenThroughAt: timestamp("frozen_through_at", { withTimezone: true, mode: "date" }), + // When property discovery (Admin API accountSummaries.list) last ran — set on the discovery + // anchor row only. Poll ticks in between reuse the known property rows. + discoveredAt: timestamp("discovered_at", { withTimezone: true, mode: "date" }), + lastSuccessAt: timestamp("last_success_at", { withTimezone: true, mode: "date" }), + lastError: text("last_error"), + lastErrorAt: timestamp("last_error_at", { withTimezone: true, mode: "date" }), + // Overlap guard: a tick claims an org's rows by bumping this past now; a competing tick that + // fails to claim skips the org. Deliberately NOT cleared on a GA4 quota rejection — it is + // held through the backoff so the next tick skips instead of re-depleting the property's + // token budget. + leaseUntil: timestamp("lease_until", { withTimezone: true, mode: "date" }), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [ + uniqueIndex("ga_analytics_state_org_property_dataset_idx").on( + table.orgId, + table.propertyId, + table.dataset, + ), + index("ga_analytics_state_org_idx").on(table.orgId), + ], +) + +export type GoogleAnalyticsStateRow = typeof googleAnalyticsState.$inferSelect +export type GoogleAnalyticsStateInsert = typeof googleAnalyticsState.$inferInsert diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 9699888fd..fa5a56066 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -13,6 +13,8 @@ export * from "./dashboards" export * from "./digest" export * from "./errors" export * from "./escalations" +export * from "./google-analytics-ledger" +export * from "./google-analytics-state" export * from "./investigations" export * from "./live-activities" export * from "./oauth-connections" diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index 9ab6b72b1..57af79962 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -9,6 +9,7 @@ import { authEnv, cloudflareOAuthEnv, derived, + googleAnalyticsOAuthEnv, ingestKeyCryptoEnv, optionalPlain, optionalSecret, @@ -227,6 +228,16 @@ describe("parity with the pre-refactor per-worker expressions", () => { "PLANETSCALE_OAUTH_TOKEN_INFO_URL", "MAPLE_PLANETSCALE_API_BASE_URL", ] + const GA_OAUTH = [ + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET", + "GOOGLE_OAUTH_SCOPES", + "GOOGLE_OAUTH_AUTHORIZE_URL", + "GOOGLE_OAUTH_TOKEN_URL", + "GOOGLE_OAUTH_REVOKE_URL", + "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL", + "MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL", + ] // A plain loop, not `describe.each` — the typed-tuple inference in `.each` // blows tsc's memory on this repo's config. @@ -337,6 +348,21 @@ describe("parity with the pre-refactor per-worker expressions", () => { } expect(unwrap(run(planetScaleOAuthEnv, env))).toEqual(unwrap(old)) }) + + it("googleAnalyticsOAuthEnv", () => { + const env = full ? populated(GA_OAUTH) : {} + const old = { + ...oldOptionalPlain(env, "GOOGLE_OAUTH_CLIENT_ID"), + ...oldOptionalSecret(env, "GOOGLE_OAUTH_CLIENT_SECRET"), + ...oldOptionalPlain(env, "GOOGLE_OAUTH_SCOPES"), + ...oldOptionalPlain(env, "GOOGLE_OAUTH_AUTHORIZE_URL"), + ...oldOptionalPlain(env, "GOOGLE_OAUTH_TOKEN_URL"), + ...oldOptionalPlain(env, "GOOGLE_OAUTH_REVOKE_URL"), + ...oldOptionalPlain(env, "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL"), + ...oldOptionalPlain(env, "MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL"), + } + expect(unwrap(run(googleAnalyticsOAuthEnv, env))).toEqual(unwrap(old)) + }) }) } diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index cfb489283..16d5b5a85 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -272,6 +272,23 @@ export const planetScaleOAuthEnv: Config.Config = merge( optionalPlain("MAPLE_PLANETSCALE_API_BASE_URL"), ) +/** + * Google Analytics 4 integration (Google OAuth — confidential client, no PKCE). + * + * `analytics.readonly` is a SENSITIVE scope: the registered Google Cloud client must clear + * brand review + OAuth verification before it can serve more than 100 users. + */ +export const googleAnalyticsOAuthEnv: Config.Config = merge( + optionalPlain("GOOGLE_OAUTH_CLIENT_ID"), + optionalSecret("GOOGLE_OAUTH_CLIENT_SECRET"), + optionalPlain("GOOGLE_OAUTH_SCOPES"), + optionalPlain("GOOGLE_OAUTH_AUTHORIZE_URL"), + optionalPlain("GOOGLE_OAUTH_TOKEN_URL"), + optionalPlain("GOOGLE_OAUTH_REVOKE_URL"), + optionalPlain("MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL"), + optionalPlain("MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL"), +) + /** Apple push (iOS app) — token auth; see `apps/api/src/platform/Apns.ts`. */ export const apnsEnv: Config.Config = merge( optionalPlain("APNS_TEAM_ID"), From 0a922befc713285da513bbd63196cd3da3538065 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 9 Sep 2026 01:59:05 +0200 Subject: [PATCH 02/11] feat(ga4): OAuth service and the collector poll loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GoogleAnalyticsOAuthService` reuses the shared connection helpers wholesale — token encryption, single-flight refresh and the `invalid_grant`-only revocation rule are not re-implemented. What it adds is Google's own trap: a refresh token is only issued with `access_type=offline`, and only RE-issued on a repeat consent with `prompt=consent`. Without both, a reconnect silently yields an access-token-only grant that dies within the hour, so a grant arriving without one is refused at connect time rather than stored — the same guard Cloudflare grew after its 31h outage. A grant that reaches no GA4 property is refused too; finding that out at the first poll means a card that looks connected and never fills in. `GoogleAnalyticsService` is the poll loop. Three frontiers rather than Cloudflare's two, because GA4 revises the past: `frozenThroughAt` is the line past which an hour is final, and everything between it and HEAD is re-polled each tick and emitted as a delta. Two bugs the tests caught, both worth naming: - Wrapping each window in `Effect.option` swallowed the failures that must abort the whole tick. A dead grant would then never be stamped revoked and would retry every 15 minutes forever, and a quota rejection would keep spending the org's remaining GA4 budget on windows guaranteed to fail the same way. Only connection-fatal errors now propagate; a malformed report for one dataset still says nothing about the next, so those stay local. - `catchTags` matched nothing. These failures carry namespaced tags ("@maple/http/errors/IntegrationsRevokedError"), so a tag-keyed catch compiled fine and silently fell through to the generic handler. Metric names follow Cloudflare's `.by_*` convention for breakdowns. That is not cosmetic: `channels`, `geo` and `device` all report the same total sliced differently, so a shared name would show four times the real session count on any chart without a group-by. --- apps/api/src/platform/Env.ts | 15 +- .../auth/GoogleAnalyticsOAuthService.ts | 367 +++++++ .../integrations/GoogleAnalyticsApi.ts | 61 +- .../GoogleAnalyticsService.test.ts | 447 +++++++++ .../integrations/GoogleAnalyticsService.ts | 921 ++++++++++++++++++ packages/infra/src/env.test.ts | 2 + packages/infra/src/env.ts | 1 + 7 files changed, 1807 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts create mode 100644 apps/api/src/services/integrations/GoogleAnalyticsService.test.ts create mode 100644 apps/api/src/services/integrations/GoogleAnalyticsService.ts diff --git a/apps/api/src/platform/Env.ts b/apps/api/src/platform/Env.ts index a7d6637dd..7fa3a23eb 100644 --- a/apps/api/src/platform/Env.ts +++ b/apps/api/src/platform/Env.ts @@ -151,6 +151,8 @@ export interface EnvConfig { readonly GOOGLE_OAUTH_AUTHORIZE_URL: string readonly GOOGLE_OAUTH_TOKEN_URL: string readonly GOOGLE_OAUTH_REVOKE_URL: string + /** OIDC userinfo — resolves the connecting Google identity for the integration card. */ + readonly GOOGLE_OAUTH_USERINFO_URL: string /** * Space-delimited OAuth scopes. `analytics.readonly` is a Google SENSITIVE scope: the * client needs brand review + OAuth verification before serving more than 100 users. @@ -319,11 +321,18 @@ const envConfig = Config.all({ ), GOOGLE_OAUTH_TOKEN_URL: stringWithDefault("GOOGLE_OAUTH_TOKEN_URL", "https://oauth2.googleapis.com/token"), GOOGLE_OAUTH_REVOKE_URL: stringWithDefault("GOOGLE_OAUTH_REVOKE_URL", "https://oauth2.googleapis.com/revoke"), - // Read-only analytics data + the property list needed to discover what to poll. - // `analytics.readonly` alone covers both the Data API and Admin API reads we make. + GOOGLE_OAUTH_USERINFO_URL: stringWithDefault( + "GOOGLE_OAUTH_USERINFO_URL", + "https://openidconnect.googleapis.com/v1/userinfo", + ), + // Read-only analytics data + the property list needed to discover what to poll: + // `analytics.readonly` alone covers both the Data API and Admin API reads we make. It is + // Google's only SENSITIVE scope here, and the one gating app verification. + // `openid email` is added so the integration card can say which Google account is connected + // and so the connection row has a stable external identity; both are non-sensitive. GOOGLE_OAUTH_SCOPES: stringWithDefault( "GOOGLE_OAUTH_SCOPES", - "https://www.googleapis.com/auth/analytics.readonly", + "https://www.googleapis.com/auth/analytics.readonly openid email", ), MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL: stringWithDefault( "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL", diff --git a/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts new file mode 100644 index 000000000..b01bb5459 --- /dev/null +++ b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts @@ -0,0 +1,367 @@ +/** + * Google OAuth for the GA4 integration. + * + * Simpler than {@link CloudflareOAuthService} in one way and stricter in another. Simpler: a + * Google grant covers one identity, not a fan-out of accounts, so the connection row needs no + * `grantedAccountsJson`. Stricter: Google only issues a refresh token when the authorize request + * asks for `access_type=offline`, and only RE-issues one on a repeat consent when + * `prompt=consent` is also sent. Without both, a reconnect silently yields an access-token-only + * grant that dies within the hour — the same failure mode as the Cloudflare `offline_access` + * outage, which is why the refusal below is copied from it verbatim. + * + * All token storage, encryption, single-flight refresh and the `invalid_grant`-only revocation + * rule come from the shared `makeOAuthConnectionHelpers`; nothing about that is re-implemented. + */ +import { randomBytes } from "node:crypto" +import { + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsValidationError, + OrgId, + type UserId, +} from "@maple/domain/http" +import { oauthAuthStates } from "@maple/db" +import { Clock, Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { listProperties } from "@/services/integrations/GoogleAnalyticsApi" +import { Database } from "@/platform/DatabaseLive" +import { Env, type EnvConfig } from "@/platform/Env" +import { dateToMs, msToDate } from "@/platform/time" +import { makeOAuthConnectionHelpers, OAUTH_STATE_TTL_MS } from "./oauth/connection-helpers" + +const GOOGLE_ANALYTICS_PROVIDER = "google_analytics" + +const decodeOrgId = Schema.decodeUnknownSync(OrgId) + +const UserInfo = Schema.Struct({ + sub: Schema.optionalKey(Schema.String), + email: Schema.optionalKey(Schema.String), +}) +const decodeUserInfo = Schema.decodeUnknownEffect(UserInfo) + +interface ResolvedGoogleOAuthConfig { + readonly clientId: string + readonly clientSecret: Redacted.Redacted | null + readonly authorizeUrl: string + readonly tokenUrl: string + readonly revokeUrl: string + readonly userInfoUrl: string + readonly scopes: string +} + +const resolveConfig = Effect.fn("GoogleAnalyticsOAuthService.resolveConfig")(function* (env: EnvConfig) { + const clientId = yield* Option.match(env.GOOGLE_OAUTH_CLIENT_ID, { + onNone: () => + Effect.fail( + new IntegrationsValidationError({ + message: "GOOGLE_OAUTH_CLIENT_ID is required to use the Google Analytics integration", + }), + ), + onSome: (value) => Effect.succeed(value), + }) + // Unlike Cloudflare's public-client flow, Google web-application clients are confidential: + // the token exchange is rejected without the secret, so a missing one is a misconfiguration + // worth failing loudly at connect time rather than at the first refresh. + const clientSecret = yield* Option.match(env.GOOGLE_OAUTH_CLIENT_SECRET, { + onNone: () => + Effect.fail( + new IntegrationsValidationError({ + message: "GOOGLE_OAUTH_CLIENT_SECRET is required to use the Google Analytics integration", + }), + ), + onSome: (value) => Effect.succeed(value), + }) + + return { + clientId, + clientSecret, + authorizeUrl: env.GOOGLE_OAUTH_AUTHORIZE_URL, + tokenUrl: env.GOOGLE_OAUTH_TOKEN_URL, + revokeUrl: env.GOOGLE_OAUTH_REVOKE_URL, + userInfoUrl: env.GOOGLE_OAUTH_USERINFO_URL, + scopes: env.GOOGLE_OAUTH_SCOPES, + } satisfies ResolvedGoogleOAuthConfig +}) + +export interface GoogleAnalyticsConnectionStatus { + readonly connected: boolean + readonly connectedAt: number | null + readonly externalUserEmail: string | null + readonly connectedByUserId: string | null + readonly scope: string + readonly revoked: boolean +} + +interface GoogleAnalyticsOAuthServiceApi { + readonly startConnect: ( + orgId: OrgId, + userId: UserId, + options: { readonly callbackUrl: string; readonly returnTo?: string }, + ) => Effect.Effect< + { readonly redirectUrl: string; readonly state: string }, + IntegrationsValidationError | IntegrationsPersistenceError + > + readonly completeConnect: ( + code: string, + state: string, + ) => Effect.Effect< + { readonly orgId: OrgId; readonly returnTo: string | null }, + | IntegrationsValidationError + | IntegrationsUpstreamError + | IntegrationsRevokedError + | IntegrationsPersistenceError + > + readonly getStatus: ( + orgId: OrgId, + ) => Effect.Effect + readonly getValidAccessToken: ( + orgId: OrgId, + ) => Effect.Effect< + { readonly accessToken: string; readonly scope: string }, + | IntegrationsNotConnectedError + | IntegrationsRevokedError + | IntegrationsUpstreamError + | IntegrationsPersistenceError + | IntegrationsValidationError + > + readonly disconnect: ( + orgId: OrgId, + ) => Effect.Effect<{ readonly disconnected: boolean }, IntegrationsPersistenceError> + /** Stamp the grant revoked so pollers stop retrying it; cleared on reconnect. Best-effort. */ + readonly markConnectionRevoked: (orgId: OrgId) => Effect.Effect +} + +export class GoogleAnalyticsOAuthService extends Context.Service< + GoogleAnalyticsOAuthService, + GoogleAnalyticsOAuthServiceApi +>()("@maple/api/services/GoogleAnalyticsOAuthService", { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const httpClient = yield* HttpClient.HttpClient + const oauth = yield* makeOAuthConnectionHelpers({ + provider: GOOGLE_ANALYTICS_PROVIDER, + providerLabel: "Google Analytics", + database, + env, + }) + + /** Best-effort token revocation on disconnect — failures are logged, never surfaced. */ + const revokeToken = (config: ResolvedGoogleOAuthConfig, token: string) => + oauth.postForm(config.revokeUrl, { token }).pipe(Effect.ignore) + + /** The connecting Google identity, for the card's "connected as" line. Never fatal. */ + const fetchUserInfo = (config: ResolvedGoogleOAuthConfig, accessToken: string) => + httpClient + .execute( + HttpClientRequest.get(config.userInfoUrl).pipe( + HttpClientRequest.setHeaders({ + authorization: `Bearer ${accessToken}`, + accept: "application/json", + }), + ), + ) + .pipe( + Effect.flatMap((response) => response.json), + Effect.flatMap(decodeUserInfo), + Effect.option, + ) + + const startConnect = Effect.fn("GoogleAnalyticsOAuthService.startConnect")(function* ( + orgId: OrgId, + userId: UserId, + options: { readonly callbackUrl: string; readonly returnTo?: string }, + ) { + yield* Effect.annotateCurrentSpan({ orgId }) + const config = yield* resolveConfig(env) + const state = randomBytes(24).toString("base64url") + const currentTime = yield* Clock.currentTimeMillis + + yield* oauth.purgeExpiredStates(currentTime) + yield* oauth.dbExecute((db) => + db.insert(oauthAuthStates).values({ + state, + orgId, + provider: GOOGLE_ANALYTICS_PROVIDER, + initiatedByUserId: userId, + redirectUri: options.callbackUrl, + returnTo: options.returnTo ?? null, + // Google web-application clients are confidential and authenticate the exchange + // with the client secret; PKCE is accepted but adds nothing here. + codeVerifier: null, + createdAt: msToDate(currentTime), + expiresAt: msToDate(currentTime + OAUTH_STATE_TTL_MS), + }), + ) + + // `access_type=offline` asks for a refresh token; `prompt=consent` forces Google to + // RE-issue one on a repeat authorization. Without the second, a user reconnecting an + // already-authorized app gets an access token only, and the poller dies within the hour. + // `include_granted_scopes` keeps any scopes the user previously granted this client. + const params = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: options.callbackUrl, + response_type: "code", + scope: config.scopes, + state, + access_type: "offline", + prompt: "consent", + include_granted_scopes: "true", + }) + return { redirectUrl: `${config.authorizeUrl}?${params.toString()}`, state } + }) + + const completeConnect = Effect.fn("GoogleAnalyticsOAuthService.completeConnect")(function* ( + code: string, + state: string, + ) { + const config = yield* resolveConfig(env) + const stateRow = yield* oauth.requireStateRow(state) + yield* oauth.deleteAuthState(state) + + const tokenResponse = yield* oauth.exchangeAuthorizationCode(config, code, stateRow.redirectUri) + const orgId = decodeOrgId(stateRow.orgId) + yield* Effect.annotateCurrentSpan({ orgId }) + + // A background poller must renew indefinitely. A grant with no refresh token silently + // stops working at the access token's ~1h expiry; refuse it loudly instead of storing a + // doomed connection. Best-effort revoke first — the token is never persisted, so this is + // the only moment we can invalidate it upstream. + if (!tokenResponse.refresh_token) { + yield* Effect.logWarning( + "Google OAuth token exchange returned no refresh token — refusing connection", + { orgId }, + ) + yield* revokeToken(config, tokenResponse.access_token) + return yield* Effect.fail( + new IntegrationsValidationError({ + message: + "Google returned no refresh token, so this connection would stop working within the hour. Remove Maple from your Google account's third-party access and connect again.", + }), + ) + } + + // A grant that reaches no GA4 property is useless, and finding out at the first poll + // means an integration card that looks connected and never fills in. Same refusal shape + // as Cloudflare's zero-accounts guard. + const properties = yield* listProperties({ + accessToken: tokenResponse.access_token, + adminBaseUrl: env.MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL, + }) + if (properties.length === 0) { + yield* revokeToken(config, tokenResponse.access_token) + return yield* Effect.fail( + new IntegrationsValidationError({ + message: + "That Google account can't see any Google Analytics 4 properties. Connect an account with at least Viewer access to a GA4 property.", + }), + ) + } + + const userInfo = yield* fetchUserInfo(config, tokenResponse.access_token) + const identity = Option.getOrElse(userInfo, () => ({}) as { sub?: string; email?: string }) + + const accessEnc = yield* oauth.encryptValue(tokenResponse.access_token) + const refreshEnc = yield* oauth.encryptValue(tokenResponse.refresh_token) + const currentTime = yield* Clock.currentTimeMillis + const expiresAt = + tokenResponse.expires_in != null ? currentTime + tokenResponse.expires_in * 1000 : null + + yield* oauth.upsertConnection(orgId, currentTime, { + // `sub` is Google's stable per-client user id. Falling back to the email keeps the + // NOT NULL column honest when userinfo is unavailable; it is a label, not a key. + externalUserId: identity.sub ?? identity.email ?? "google-analytics", + externalUserEmail: identity.email ?? null, + externalAccountName: properties[0]?.accountName ?? null, + grantedAccountsJson: null, + connectedByUserId: stateRow.initiatedByUserId, + scope: tokenResponse.scope ?? config.scopes, + accessTokenCiphertext: accessEnc.ciphertext, + accessTokenIv: accessEnc.iv, + accessTokenTag: accessEnc.tag, + refreshTokenCiphertext: refreshEnc.ciphertext, + refreshTokenIv: refreshEnc.iv, + refreshTokenTag: refreshEnc.tag, + expiresAt: msToDate(expiresAt), + }) + + return { orgId, returnTo: stateRow.returnTo ?? null } + }) + + const getValidAccessToken = Effect.fn("GoogleAnalyticsOAuthService.getValidAccessToken")( + function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const config = yield* resolveConfig(env) + const { accessToken, row } = yield* oauth.getValidConnectionToken(config, orgId) + return { accessToken, scope: row.scope } + }, + ) + + const getStatus = Effect.fn("GoogleAnalyticsOAuthService.getStatus")(function* (orgId: OrgId) { + const row = yield* oauth.loadConnection(orgId) + if (!row) { + return { + connected: false, + connectedAt: null, + externalUserEmail: null, + connectedByUserId: null, + scope: "", + revoked: false, + } satisfies GoogleAnalyticsConnectionStatus + } + return { + connected: true, + connectedAt: dateToMs(row.createdAt), + externalUserEmail: row.externalUserEmail, + connectedByUserId: row.connectedByUserId, + scope: row.scope, + revoked: row.revokedAt != null, + } satisfies GoogleAnalyticsConnectionStatus + }) + + const disconnect = Effect.fn("GoogleAnalyticsOAuthService.disconnect")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + // Best-effort upstream revocation before the row goes; a failure here must never block + // the disconnect, because the deleted row is the real backstop. + const row = yield* oauth.loadConnection(orgId) + if (row) { + const config = yield* resolveConfig(env).pipe(Effect.option) + // Revoking the REFRESH token invalidates the whole grant at Google, access token + // included — revoking the access token alone would leave the refresh grant live. + const refreshToken = + row.refreshTokenCiphertext && row.refreshTokenIv && row.refreshTokenTag + ? yield* oauth + .decryptValue({ + ciphertext: row.refreshTokenCiphertext, + iv: row.refreshTokenIv, + tag: row.refreshTokenTag, + }) + .pipe(Effect.option) + : Option.none() + if (Option.isSome(config) && Option.isSome(refreshToken)) { + yield* revokeToken(config.value, refreshToken.value) + } + } + return yield* oauth.deleteConnection(orgId) + }) + + const markConnectionRevoked = Effect.fn("GoogleAnalyticsOAuthService.markConnectionRevoked")( + function* (orgId: OrgId) { + yield* oauth.markConnectionRevoked(orgId) + }, + ) + + return { + startConnect, + completeConnect, + getStatus, + getValidAccessToken, + disconnect, + markConnectionRevoked, + } satisfies GoogleAnalyticsOAuthServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/apps/api/src/services/integrations/GoogleAnalyticsApi.ts b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts index 6b455ddb9..f2a762ee0 100644 --- a/apps/api/src/services/integrations/GoogleAnalyticsApi.ts +++ b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts @@ -20,7 +20,16 @@ */ import { IntegrationsRevokedError, IntegrationsUpstreamError } from "@maple/domain/http" import { Effect, Schema } from "effect" -import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" + +/** + * Each call provides its own client rather than demanding one from the caller's context — the + * same shape `CloudflareApiImpl` uses, so these stay callable from a service closure without + * leaking `HttpClient` into its requirement channel. `FetchHttpClient.Fetch` is a + * `Context.Reference` with a default, so a test that overrides it further out still wins. + */ +const withHttpClient = (effect: Effect.Effect) => + effect.pipe(Effect.provide(FetchHttpClient.layer)) /** GA4 quota denial — the caller backs off rather than retrying within the tick. */ export const GA_QUOTA_STATUS = 429 @@ -68,8 +77,8 @@ const RunReportResponse = Schema.Struct({ export type GoogleAnalyticsRunReportResponse = typeof RunReportResponse.Type -const decodeAccountSummaries = Schema.decodeUnknown(AccountSummariesResponse) -const decodeRunReport = Schema.decodeUnknown(RunReportResponse) +const decodeAccountSummaries = Schema.decodeUnknownEffect(AccountSummariesResponse) +const decodeRunReport = Schema.decodeUnknownEffect(RunReportResponse) export interface GoogleAnalyticsProperty { /** Bare id ("123456789"), with the API's "properties/" resource prefix stripped. */ @@ -204,7 +213,51 @@ export const listProperties = Effect.fn("GoogleAnalyticsApi.listProperties")(fun } return properties +}, withHttpClient) + +const PropertyDetail = Schema.Struct({ + displayName: Schema.optionalKey(Schema.String), + timeZone: Schema.optionalKey(Schema.String), }) +const decodePropertyDetail = Schema.decodeUnknownEffect(PropertyDetail) + +/** + * A property's IANA reporting timezone. Not carried by `accountSummaries`, and required before a + * property can be polled at all — `dateHour` is expressed in it — so this is fetched once per + * property and cached on the state row rather than per discovery pass. + */ +export const getPropertyTimeZone = Effect.fn("GoogleAnalyticsApi.getPropertyTimeZone")( + function* (options: { + readonly accessToken: string + readonly adminBaseUrl: string + readonly propertyId: string + }) { + const httpClient = yield* HttpClient.HttpClient + const url = `${options.adminBaseUrl.replace(/\/+$/, "")}/properties/${options.propertyId}` + const response = yield* httpClient + .execute(authorized(HttpClientRequest.get(url), options.accessToken)) + .pipe( + Effect.annotateSpans("peer.service", "google-analytics-admin"), + Effect.catchTag("HttpClientError", (error) => + Effect.fail(upstream(`Google Analytics admin request failed: ${error.message}`, undefined, error)), + ), + ) + + if (response.status >= 300) { + const text = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* Effect.fail(classifyFailure(response.status, text, "Admin API")) + } + + const json = yield* response.json.pipe( + Effect.mapError(() => upstream("Google Analytics Admin API returned a non-JSON response")), + ) + const detail = yield* decodePropertyDetail(json).pipe( + Effect.mapError(() => upstream("Google Analytics Admin API returned an unexpected payload")), + ) + return { timeZone: detail.timeZone ?? null, displayName: detail.displayName ?? null } + }, + withHttpClient, +) /** One Data API `runReport` against a single property. */ export const runReport = Effect.fn("GoogleAnalyticsApi.runReport")(function* (options: { @@ -255,6 +308,6 @@ export const runReport = Effect.fn("GoogleAnalyticsApi.runReport")(function* (op return yield* decodeRunReport(json).pipe( Effect.mapError(() => upstream("Google Analytics Data API returned an unexpected payload")), ) -}) +}, withHttpClient) export type GoogleAnalyticsApiError = IntegrationsUpstreamError | IntegrationsRevokedError diff --git a/apps/api/src/services/integrations/GoogleAnalyticsService.test.ts b/apps/api/src/services/integrations/GoogleAnalyticsService.test.ts new file mode 100644 index 000000000..d96e34c9b --- /dev/null +++ b/apps/api/src/services/integrations/GoogleAnalyticsService.test.ts @@ -0,0 +1,447 @@ +// SAFETY-FILE: JSON in this test is emitted by the fixture or unit under test before its fields are asserted. +import { afterEach, assert, describe, it } from "@effect/vitest" +import { OrgId } from "@maple/domain/http" +import { googleAnalyticsLedger, googleAnalyticsState, oauthConnections } from "@maple/db" +import { ConfigProvider, Effect, Layer, Schema } from "effect" +import { TestClock } from "effect/testing" +import { FetchHttpClient } from "effect/unstable/http" +import { eq } from "drizzle-orm" +import { encryptAes256Gcm, parseBase64Aes256GcmKey } from "@/platform/Crypto" +import { Database } from "@/platform/DatabaseLive" +import { Env } from "@/platform/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "@/platform/test-pglite" +import { GoogleAnalyticsOAuthService } from "@/services/auth/GoogleAnalyticsOAuthService" +import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" +import { GoogleAnalyticsService } from "./GoogleAnalyticsService" +import type { OtlpMetricsPayload } from "./shared/otlp" + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +const asOrgId = Schema.decodeUnknownSync(OrgId) +const ORG = asOrgId("org_ga") +const PROPERTY_ID = "123456789" + +const ENCRYPTION_KEY_B64 = Buffer.alloc(32, 7).toString("base64") + +const baseConfig = { + PORT: "3472", + TINYBIRD_HOST: "https://api.tinybird.co", + TINYBIRD_TOKEN: "test-token", + MAPLE_AUTH_MODE: "self_hosted", + MAPLE_ROOT_PASSWORD: "test-root-password", + MAPLE_DEFAULT_ORG_ID: "default", + MAPLE_INGEST_KEY_ENCRYPTION_KEY: ENCRYPTION_KEY_B64, + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.example.com", + GOOGLE_OAUTH_CLIENT_ID: "google-client-id", + GOOGLE_OAUTH_CLIENT_SECRET: "google-client-secret", +} + +/** Sessions GA4 reports for a given `dateHour`, per call. Successive polls read successive entries. */ +interface FetchOptions { + /** One entry per `runReport` call: dateHour → sessions. */ + readonly reports: ReadonlyArray> + readonly timeZone?: string + /** Force every Data API call to fail with this HTTP status. */ + readonly dataApiStatus?: number + readonly dataApiBody?: string + otlpCalls: Array + reportCalls: Array<{ propertyId: string; body: unknown }> +} + +/** T0's property-local date — see {@link T0}. Declared here because the fetch mock needs it. */ +const T0_DATE = "2026-09-09" + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +/** + * `HttpClientRequest.bodyJsonUnsafe` does not hand `fetch` a string, so `String(init.body)` yields + * "[object Object]" and every JSON.parse in the mock throws — which surfaces as an upstream + * failure on the real code path rather than as an obviously broken test. + */ +const readBody = async (init: RequestInit | undefined): Promise => { + if (init?.body == null) return "" + return typeof init.body === "string" ? init.body : await new Response(init.body as BodyInit).text() +} + +/** + * Stands in for Google's two APIs plus the ingest gateway. Only the `traffic` dataset returns + * rows; the other five report nothing, which keeps assertions about emitted values unambiguous. + */ +const mockGoogleFetch = (options: FetchOptions): typeof globalThis.fetch => { + let reportIndex = 0 + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url + + if (url.includes("/v1/metrics")) { + options.otlpCalls.push(JSON.parse(await readBody(init)) as OtlpMetricsPayload) + return json({ ok: true }) + } + + if (url.includes("/accountSummaries")) { + return json({ + accountSummaries: [ + { + displayName: "Acme", + propertySummaries: [{ property: `properties/${PROPERTY_ID}`, displayName: "example.com" }], + }, + ], + }) + } + + if (url.includes(":runReport")) { + if (options.dataApiStatus !== undefined) { + return new Response(options.dataApiBody ?? "{}", { status: options.dataApiStatus }) + } + const body = JSON.parse(await readBody(init)) as { + dimensions: Array<{ name: string }> + metrics: Array<{ name: string }> + dateRanges: Array<{ startDate: string; endDate: string }> + } + const propertyId = url.split("/properties/")[1]?.split(":")[0] ?? "" + options.reportCalls.push({ propertyId, body }) + + // Breakdown datasets report nothing — only `traffic` (dateHour alone) carries values. + if (body.dimensions.length !== 1) return json({ rows: [] }) + // Each poll asks twice for `traffic`: the live head window and one backfill round. + // Only the head window covers T0's hour, so the backfill legitimately reports nothing — + // and, more to the point, must not consume a fixture entry, or "one entry per poll" + // would silently become "one per call" and the revision assertions would drift. + if (body.dateRanges[0]?.endDate !== T0_DATE) return json({ rows: [] }) + + const report = options.reports[Math.min(reportIndex, options.reports.length - 1)] + reportIndex += 1 + const metricNames = body.metrics.map((metric) => metric.name) + return json({ + dimensionHeaders: [{ name: "dateHour" }], + metricHeaders: metricNames.map((name) => ({ name })), + rows: [...(report ?? new Map())].map(([dateHour, sessions]) => ({ + dimensionValues: [{ value: dateHour }], + // Only `sessions` carries a value; the rest report zero. + metricValues: metricNames.map((name) => ({ value: name === "sessions" ? String(sessions) : "0" })), + })), + }) + } + + // properties/{id} — the timezone lookup. + if (url.includes("/properties/")) { + return json({ displayName: "example.com", timeZone: options.timeZone ?? "UTC" }) + } + + return json({ error: { message: `unexpected url ${url}` } }, 500) + }) as typeof globalThis.fetch +} + +const makeLayer = (testDb: TestDb, fetchOptions: FetchOptions) => + GoogleAnalyticsService.layer.pipe( + Layer.provideMerge(GoogleAnalyticsOAuthService.layer), + Layer.provideMerge(OrgIngestKeysService.layer), + Layer.provideMerge(testDb.layer), + Layer.provideMerge(Env.layer), + Layer.provideMerge(ConfigProvider.layer(ConfigProvider.fromUnknown(baseConfig))), + Layer.provideMerge(Layer.succeed(FetchHttpClient.Fetch, mockGoogleFetch(fetchOptions))), + ) + +/** A live grant with a real encrypted, non-expiring access token. */ +const seedConnection = Effect.gen(function* () { + const database = yield* Database + const key = yield* parseBase64Aes256GcmKey(ENCRYPTION_KEY_B64, (message) => new Error(message)) + const accessEnc = yield* encryptAes256Gcm("ga-access-token", key, (message) => new Error(message)) + const refreshEnc = yield* encryptAes256Gcm("ga-refresh-token", key, (message) => new Error(message)) + yield* database.execute((db) => + db.insert(oauthConnections).values({ + id: "conn-ga", + orgId: ORG, + provider: "google_analytics", + externalUserId: "google-sub", + externalUserEmail: "owner@example.com", + externalAccountName: "Acme", + connectedByUserId: "user_1", + scope: "https://www.googleapis.com/auth/analytics.readonly", + accessTokenCiphertext: accessEnc.ciphertext, + accessTokenIv: accessEnc.iv, + accessTokenTag: accessEnc.tag, + refreshTokenCiphertext: refreshEnc.ciphertext, + refreshTokenIv: refreshEnc.iv, + refreshTokenTag: refreshEnc.tag, + // Far future: the refresh path is exercised by the shared OAuth helper's own tests. + expiresAt: new Date(Date.now() + 86_400_000), + createdAt: new Date(), + updatedAt: new Date(), + }), + ) +}) + +/** Every `google_analytics.sessions` data point across all OTLP batches, in order. */ +const sessionPoints = (calls: ReadonlyArray) => + calls.flatMap((payload) => + payload.resourceMetrics.flatMap((resource) => + resource.scopeMetrics.flatMap((scope) => + scope.metrics + .filter((metric) => metric.name === "google_analytics.sessions") + .flatMap((metric) => metric.sum?.dataPoints ?? []), + ), + ), + ) + +/** + * Fixed test wall-clock, and the `dateHour` that matches it. + * + * `it.effect` runs on a TestClock that starts at epoch 0, so every window the collector computes + * would sit in 1970 and no fixture hour would ever fall inside it — the reconciliation assertions + * would pass vacuously against an empty result. Each test sets the clock to T0 first, and the + * fixture reports for T0's own hour. + */ +const T0 = Date.parse("2026-09-09T14:00:00Z") +const HOUR = "2026090914" + +const options = (reports: ReadonlyArray>, extra: Partial = {}) => + ({ reports, otlpCalls: [], reportCalls: [], ...extra }) satisfies FetchOptions + +describe("GoogleAnalyticsService", () => { + it.effect("discovers properties and creates a state row per dataset", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 10]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + + const database = yield* Database + const rows = yield* database.execute((db) => + db.select().from(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, ORG)), + ) + // Six datasets plus the discovery anchor. + assert.strictEqual(rows.length, 7) + const anchor = rows.find((row) => row.dataset === "__discovery__") + assert.isNotNull(anchor?.discoveredAt ?? null) + const traffic = rows.find((row) => row.dataset === "traffic") + assert.strictEqual(traffic?.propertyName, "example.com") + assert.strictEqual(traffic?.accountName, "Acme") + assert.strictEqual(traffic?.timeZone, "UTC") + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("emits the raw value first, then only the delta on a revision", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + // Second poll revises the same hour upward; third revises it back down. + const fetchOptions = options([ + new Map([[HOUR, 100]]), + new Map([[HOUR, 140]]), + new Map([[HOUR, 60]]), + ]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + yield* service.pollOrg(ORG) + yield* service.pollOrg(ORG) + + const points = sessionPoints(fetchOptions.otlpCalls) + assert.deepStrictEqual( + points.map((point) => point.asDouble), + [100, 40, -80], + ) + // The whole point: the deltas sum to GA4's latest answer for the bucket. + assert.strictEqual( + points.reduce((total, point) => total + point.asDouble, 0), + 60, + ) + // ...and every one of them is a DELTA sum at the same instant. + const timestamps = new Set(points.map((point) => point.timeUnixNano)) + assert.strictEqual(timestamps.size, 1) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("writes nothing when a re-poll finds no change", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 100]]), new Map([[HOUR, 100]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + const afterFirst = fetchOptions.otlpCalls.length + yield* service.pollOrg(ORG) + + assert.isAbove(afterFirst, 0) + assert.strictEqual(sessionPoints(fetchOptions.otlpCalls).length, 1) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("records what it emitted in the ledger, scoped to the property and dataset", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 100]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + + const database = yield* Database + const ledger = yield* database.execute((db) => + db.select().from(googleAnalyticsLedger).where(eq(googleAnalyticsLedger.orgId, ORG)), + ) + // Only `traffic` returned rows, so only it has a ledger entry: the five breakdown + // datasets emitted nothing and must not accrue empty rows. + assert.strictEqual(ledger.length, 1) + assert.strictEqual(ledger[0]?.dataset, "traffic") + assert.strictEqual(ledger[0]?.propertyId, PROPERTY_ID) + const emitted = JSON.parse(ledger[0]?.emittedJson ?? "{}") as Record + assert.strictEqual(emitted["google_analytics.sessions"], 100) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("records the failure and keeps the frontier when the Data API errors", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map()], { + dataApiStatus: 500, + dataApiBody: '{"error":{"status":"INTERNAL"}}', + }) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + const result = yield* service.pollOrg(ORG) + + assert.isAbove(result.failures, 0) + const database = yield* Database + const rows = yield* database.execute((db) => + db.select().from(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, ORG)), + ) + const traffic = rows.find((row) => row.dataset === "traffic") + // Nothing landed, so the head frontier must not have moved. + assert.isNull(traffic?.watermarkAt ?? null) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("stamps the connection revoked when Google rejects the grant", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map()], { + dataApiStatus: 401, + dataApiBody: '{"error":{"status":"UNAUTHENTICATED"}}', + }) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + + const database = yield* Database + const rows = yield* database.execute((db) => + db.select().from(oauthConnections).where(eq(oauthConnections.orgId, ORG)), + ) + assert.isNotNull(rows[0]?.revokedAt ?? null) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("holds the lease through a quota backoff instead of clearing it", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map()], { + dataApiStatus: 429, + dataApiBody: '{"error":{"status":"RESOURCE_EXHAUSTED"}}', + }) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + + const database = yield* Database + const rows = yield* database.execute((db) => + db.select().from(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, ORG)), + ) + const leased = rows.filter((row) => row.leaseUntil != null && row.leaseUntil > new Date()) + // The lease must still be in the future — clearing it would let the next tick spend + // the rest of the org's GA4 budget immediately. + assert.isAbove(leased.length, 0) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("skips a property the user disabled", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 10]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + yield* service.setPropertyEnabled(ORG, PROPERTY_ID, false) + + const before = fetchOptions.reportCalls.length + yield* service.pollOrg(ORG) + assert.strictEqual(fetchOptions.reportCalls.length, before) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("reports per-property status for the integration card", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 10]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + + const status = yield* service.getIntegrationStatus(ORG) + assert.isTrue(status.connected) + assert.strictEqual(status.externalUserEmail, "owner@example.com") + // One entry per PROPERTY, not per dataset row. + assert.strictEqual(status.properties.length, 1) + assert.strictEqual(status.properties[0]?.propertyId, PROPERTY_ID) + assert.strictEqual(status.properties[0]?.timeZone, "UTC") + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) + + it.effect("clears all collector state on reset", () => + Effect.gen(function* () { + const testDb = createTestDb(trackedDbs) + const fetchOptions = options([new Map([[HOUR, 10]])]) + + yield* Effect.gen(function* () { + yield* TestClock.setTime(T0) + yield* seedConnection + const service = yield* GoogleAnalyticsService + yield* service.pollOrg(ORG) + yield* service.resetOrgState(ORG) + + const database = yield* Database + const rows = yield* database.execute((db) => + db.select().from(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, ORG)), + ) + assert.strictEqual(rows.length, 0) + }).pipe(Effect.provide(makeLayer(testDb, fetchOptions))) + }), + ) +}) diff --git a/apps/api/src/services/integrations/GoogleAnalyticsService.ts b/apps/api/src/services/integrations/GoogleAnalyticsService.ts new file mode 100644 index 000000000..15a9d49ed --- /dev/null +++ b/apps/api/src/services/integrations/GoogleAnalyticsService.ts @@ -0,0 +1,921 @@ +// BOUNDARY: This module owns unparsed external values and narrows them before domain use. +/** + * Google Analytics 4 collector. + * + * Polls the GA4 Data API for every org with a connected Google account and writes the results + * into the regular OTel metrics pipeline (`metrics_sum`), so the metric explorer, dashboard + * builder and alerting all work on web-analytics data with zero new query paths — the same trade + * that makes {@link CloudflareAnalyticsService} cheap. + * + * The datasets are described by the {@link DATASETS} registry; one generic poll pipeline drives + * them all, one Data API call per (property, dataset, window). + * + * State lives in `google_analytics_state` (one row per org × property × dataset, plus a discovery + * anchor row per org) and `google_analytics_ledger`. Three frontiers, not Cloudflare's two: + * + * - `watermarkAt` — HEAD: end of the newest hour ingested. + * - `backfillAt` — history, walking down toward {@link BACKFILL_FLOOR_MS}. + * - `frozenThroughAt` — the one GA4 forces on us. GA4 revises `dateHour` for ~48h, so an hour is + * only final once it falls behind this line. Everything between here and HEAD is re-polled and + * emitted as a DELTA against the ledger (see `reconcile.ts`); everything behind it is frozen and + * its ledger rows are pruned. + * + * Delivery is at-least-once in the same narrow sense as the Cloudflare poller: a crash between the + * gateway accepting a batch and the ledger write landing re-emits that window's deltas next tick. + * Unlike Cloudflare's replay, this one self-heals — the next successful reconcile diffs against + * the last PERSISTED ledger, so the bucket converges on GA4's answer rather than drifting. + */ +import { + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + UserId as UserIdSchema, + type OrgId, +} from "@maple/domain/http" +import { + googleAnalyticsLedger, + googleAnalyticsState, + oauthConnections, + type GoogleAnalyticsStateRow, +} from "@maple/db" +import { and, eq, gte, inArray, isNull, lt, or, sql } from "drizzle-orm" +import { Clock, Context, Effect, Layer, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Database } from "@/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@/platform/db-execute" +import { Env } from "@/platform/Env" +import { msToDate } from "@/platform/time" +import type { MetricSumRow } from "@/services/warehouse/metric-rows" +import { GoogleAnalyticsOAuthService } from "@/services/auth/GoogleAnalyticsOAuthService" +import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" +import { + getPropertyTimeZone, + listProperties, + runReport, + GA_QUOTA_STATUS, + type GoogleAnalyticsProperty, +} from "./GoogleAnalyticsApi" +import { + DATASETS, + DISCOVERY_DATASET, + DISCOVERY_PROPERTY_ID, + type GaDatasetDef, +} from "./google-analytics/datasets" +import { mapReport } from "./google-analytics/mapping" +import { type LedgerBucket, parseLedger, reconcile, serializeLedger } from "./google-analytics/reconcile" +import { utcMsToZonedDate } from "./google-analytics/timezone" +import { metricRowsToOtlp } from "./shared/otlp" + +const HOUR_MS = 3_600_000 + +/** + * How long GA4 may keep revising an hour. Google documents "up to 48 hours" for full processing + * of a standard property; everything inside this window is re-polled and reconciled. + */ +const RESTATEMENT_WINDOW_MS = 48 * HOUR_MS + +/** + * Re-polled on EVERY tick. Fresh data changes fastest, so the cheap frequent pass covers only the + * recent tail; the expensive full-window sweep runs on {@link FULL_RECONCILE_INTERVAL_MS}. + */ +const HEAD_WINDOW_MS = 3 * HOUR_MS + +/** How often the whole 48h restatement window is swept. Hourly is well inside GA4's revision pace. */ +const FULL_RECONCILE_INTERVAL_MS = HOUR_MS + +/** How far back a newly connected property backfills. */ +const BACKFILL_FLOOR_MS = 30 * 24 * HOUR_MS + +/** One backfill round per dataset per tick, so history fills in behind live data. */ +const BACKFILL_ROUND_MS = 24 * HOUR_MS + +/** + * Lease held while a tick polls an org. Longer than the 15-minute cron interval would stall + * recovery after a crash; shorter than a slow tick would let two ticks overlap. + */ +const LEASE_MS = 10 * 60_000 + +/** + * Held (not cleared) after a quota rejection so the next tick skips the org instead of + * re-depleting the property's GA4 token budget — the lesson the Cloudflare poller learned the + * expensive way. + */ +const QUOTA_BACKOFF_MS = 30 * 60_000 + +/** + * Ceiling on Data API calls per org per tick. GA4 meters tokens per property per day and per + * hour, and a grant may cover many properties; the budget is what stops one agency-sized org from + * spending its whole hourly allowance in a single tick. + */ +const MAX_CALLS_PER_ORG_TICK = 30 + +const ORG_CONCURRENCY = 3 + +/** Property discovery TTL. Properties are created rarely; hourly is generous. */ +const DISCOVERY_TTL_MS = HOUR_MS + +/** GA4 caps a report at 250k rows; 10k is ample per window and keeps responses small. */ +const REPORT_ROW_LIMIT = 10_000 + +/** Attribution for the ingest key this collector mints on an org's behalf. */ +const SYSTEM_USER_ID = Schema.decodeUnknownSync(UserIdSchema)("system-google-analytics") + +const floorToHour = (ms: number) => Math.floor(ms / HOUR_MS) * HOUR_MS + +export interface GoogleAnalyticsPropertyStatus { + readonly propertyId: string + readonly propertyName: string | null + readonly accountName: string | null + readonly timeZone: string | null + readonly enabled: boolean + readonly lastSyncedAt: number | null + readonly lastError: string | null + readonly watermarkAt: number | null + readonly backfillAt: number | null +} + +export interface GoogleAnalyticsIntegrationStatus { + readonly connected: boolean + readonly connectedAt: number | null + readonly externalUserEmail: string | null + readonly revoked: boolean + readonly properties: ReadonlyArray +} + +export interface GoogleAnalyticsPollResult { + readonly properties: number + readonly rowsIngested: number + readonly skipped: number + readonly failures: number +} + +interface GoogleAnalyticsServiceApi { + /** Cron entry point: every org with a live grant, bounded concurrency. */ + readonly pollAllOrgs: () => Effect.Effect + /** One org, used by the cron fan-out and by the post-connect prime. */ + readonly pollOrg: (orgId: OrgId) => Effect.Effect + readonly getIntegrationStatus: ( + orgId: OrgId, + ) => Effect.Effect + readonly setPropertyEnabled: ( + orgId: OrgId, + propertyId: string, + enabled: boolean, + ) => Effect.Effect + /** Drop all collector state for an org — called after a disconnect. */ + readonly resetOrgState: (orgId: OrgId) => Effect.Effect +} + +export class GoogleAnalyticsService extends Context.Service< + GoogleAnalyticsService, + GoogleAnalyticsServiceApi +>()("@maple/api/services/GoogleAnalyticsService", { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const oauth = yield* GoogleAnalyticsOAuthService + const ingestKeys = yield* OrgIngestKeysService + const httpClient = yield* HttpClient.HttpClient + + const toPersistenceError = makePersistenceErrorMapper( + IntegrationsPersistenceError, + "Google Analytics integration storage is unavailable", + ) + const dbExecute = makeDbExecute(database, "GoogleAnalyticsService", toPersistenceError) + + const adminBaseUrl = env.MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL + const dataBaseUrl = env.MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL + const ingestMetricsUrl = `${env.MAPLE_INGEST_PUBLIC_URL.replace(/\/+$/, "")}/v1/metrics` + + // ── State access ────────────────────────────────────────────────────── + + const loadRows = (orgId: OrgId) => + dbExecute((db) => + db.select().from(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, orgId)), + ) + + const rowId = (orgId: OrgId, propertyId: string, dataset: string) => + `${orgId}:${propertyId}:${dataset}` + + const upsertRow = (row: { + readonly orgId: OrgId + readonly propertyId: string + readonly dataset: string + readonly propertyName: string | null + readonly accountName: string | null + readonly now: number + }) => + dbExecute((db) => + db + .insert(googleAnalyticsState) + .values({ + id: rowId(row.orgId, row.propertyId, row.dataset), + orgId: row.orgId, + propertyId: row.propertyId, + dataset: row.dataset, + propertyName: row.propertyName, + accountName: row.accountName, + createdAt: msToDate(row.now), + updatedAt: msToDate(row.now), + }) + .onConflictDoUpdate({ + target: [ + googleAnalyticsState.orgId, + googleAnalyticsState.propertyId, + googleAnalyticsState.dataset, + ], + // Names refresh on every discovery, but `enabled` is deliberately NOT reset: + // a property the user switched off must stay off across discovery passes. + set: { + propertyName: row.propertyName, + accountName: row.accountName, + updatedAt: msToDate(row.now), + }, + }), + ) + + const patchRow = (id: string, patch: Partial) => + dbExecute((db) => db.update(googleAnalyticsState).set(patch).where(eq(googleAnalyticsState.id, id))) + + /** + * Claim the org for this tick. One conditional UPDATE across the org's rows: whoever moves + * `leaseUntil` past now owns the tick, and a competing tick reads zero updated rows and + * skips. Returns false when the org is already claimed. + */ + const claimLease = Effect.fn("GoogleAnalyticsService.claimLease")(function* ( + orgId: OrgId, + now: number, + ) { + const claimed = yield* dbExecute((db) => + db + .update(googleAnalyticsState) + .set({ leaseUntil: msToDate(now + LEASE_MS), updatedAt: msToDate(now) }) + .where( + and( + eq(googleAnalyticsState.orgId, orgId), + or(isNull(googleAnalyticsState.leaseUntil), lt(googleAnalyticsState.leaseUntil, msToDate(now))), + ), + ) + .returning({ id: googleAnalyticsState.id }), + ) + return claimed.length > 0 + }) + + const releaseLease = (orgId: OrgId, until: number | null, now: number) => + dbExecute((db) => + db + .update(googleAnalyticsState) + .set({ leaseUntil: until === null ? null : msToDate(until), updatedAt: msToDate(now) }) + .where(eq(googleAnalyticsState.orgId, orgId)), + ) + + const recordOrgError = (orgId: OrgId, message: string, now: number) => + dbExecute((db) => + db + .update(googleAnalyticsState) + .set({ + lastError: message.slice(0, 500), + lastErrorAt: msToDate(now), + updatedAt: msToDate(now), + }) + .where(eq(googleAnalyticsState.orgId, orgId)), + ) + + // ── Ledger ──────────────────────────────────────────────────────────── + + const loadLedger = (orgId: OrgId, propertyId: string, dataset: string, fromMs: number) => + dbExecute((db) => + db + .select() + .from(googleAnalyticsLedger) + .where( + and( + eq(googleAnalyticsLedger.orgId, orgId), + eq(googleAnalyticsLedger.propertyId, propertyId), + eq(googleAnalyticsLedger.dataset, dataset), + gte(googleAnalyticsLedger.bucketAt, msToDate(fromMs)), + ), + ), + ).pipe( + Effect.map((rows): ReadonlyArray => + rows.map((row) => ({ + bucketMs: row.bucketAt.getTime(), + emitted: parseLedger(row.emittedJson), + })), + ), + ) + + const saveLedger = Effect.fn("GoogleAnalyticsService.saveLedger")(function* (options: { + readonly orgId: OrgId + readonly propertyId: string + readonly dataset: string + readonly buckets: ReadonlyArray + readonly now: number + }) { + // Buckets that ended up with nothing emitted carry no information and are deleted + // rather than stored as `{}` — otherwise a quiet property accretes a row per hour. + const empty = options.buckets.filter((bucket) => Object.keys(bucket.emitted).length === 0) + const populated = options.buckets.filter((bucket) => Object.keys(bucket.emitted).length > 0) + + if (populated.length > 0) { + yield* dbExecute((db) => + db + .insert(googleAnalyticsLedger) + .values( + populated.map((bucket) => ({ + id: `${options.orgId}:${options.propertyId}:${options.dataset}:${bucket.bucketMs}`, + orgId: options.orgId, + propertyId: options.propertyId, + dataset: options.dataset, + bucketAt: msToDate(bucket.bucketMs), + emittedJson: serializeLedger(bucket.emitted), + createdAt: msToDate(options.now), + updatedAt: msToDate(options.now), + })), + ) + .onConflictDoUpdate({ + target: [ + googleAnalyticsLedger.orgId, + googleAnalyticsLedger.propertyId, + googleAnalyticsLedger.dataset, + googleAnalyticsLedger.bucketAt, + ], + // `excluded` is the row this statement tried to insert — the multi-row upsert + // needs each conflict to take ITS OWN new blob, not one value for all. + set: { + emittedJson: sql`excluded.emitted_json`, + updatedAt: msToDate(options.now), + }, + }), + ) + } + + if (empty.length > 0) { + yield* dbExecute((db) => + db.delete(googleAnalyticsLedger).where( + and( + eq(googleAnalyticsLedger.orgId, options.orgId), + eq(googleAnalyticsLedger.propertyId, options.propertyId), + eq(googleAnalyticsLedger.dataset, options.dataset), + inArray( + googleAnalyticsLedger.bucketAt, + empty.map((bucket) => msToDate(bucket.bucketMs)), + ), + ), + ), + ) + } + }) + + /** Drop ledger rows for hours GA4 can no longer revise — they are settled by definition. */ + const pruneLedger = (orgId: OrgId, propertyId: string, dataset: string, frozenThroughMs: number) => + dbExecute((db) => + db + .delete(googleAnalyticsLedger) + .where( + and( + eq(googleAnalyticsLedger.orgId, orgId), + eq(googleAnalyticsLedger.propertyId, propertyId), + eq(googleAnalyticsLedger.dataset, dataset), + lt(googleAnalyticsLedger.bucketAt, msToDate(frozenThroughMs)), + ), + ), + ) + + // ── Ingest ──────────────────────────────────────────────────────────── + + const getOrgIngestKey = (orgId: OrgId) => + ingestKeys.getOrCreate(orgId, SYSTEM_USER_ID).pipe(Effect.map((keys) => keys.publicKey)) + + /** + * Ship reconciled deltas to the ingest gateway as one OTLP/JSON request, so per-org routing + * (managed Tinybird vs BYO ClickHouse), schema-version gating, WAL durability and Autumn + * metering all apply exactly as they do for the org's own telemetry. + */ + const emitMetrics = Effect.fn("GoogleAnalyticsService.emitMetrics")( + function* (ingestKey: string, rows: ReadonlyArray) { + if (rows.length === 0) return 0 + const request = HttpClientRequest.post(ingestMetricsUrl, { + headers: { authorization: `Bearer ${ingestKey}`, "content-type": "application/json" }, + }).pipe(HttpClientRequest.bodyJsonUnsafe(metricRowsToOtlp(rows, []))) + const response = yield* httpClient + .execute(request) + .pipe(Effect.annotateSpans("peer.service", "ingest")) + if (response.status >= 300) { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")) + return yield* Effect.fail( + new IntegrationsUpstreamError({ + message: `Google Analytics metrics ingest returned ${response.status}: ${body.slice(0, 300)}`, + status: response.status, + }), + ) + } + return rows.length + }, + (effect) => + effect.pipe( + Effect.mapError((error) => + error instanceof IntegrationsUpstreamError + ? error + : new IntegrationsUpstreamError({ + message: `Google Analytics metrics ingest request failed: ${error instanceof Error ? error.message : String(error)}`, + cause: error, + }), + ), + ), + ) + + // ── Poll ────────────────────────────────────────────────────────────── + + /** + * One (property, dataset, window): fetch, map, reconcile against the ledger, ingest, then + * persist. The ledger is written only AFTER the gateway accepts the batch — the other order + * would record deltas as emitted that never landed, and those are unrecoverable. + */ + const pollWindow = Effect.fn("GoogleAnalyticsService.pollWindow")(function* (context: { + readonly orgId: OrgId + readonly row: GoogleAnalyticsStateRow + readonly dataset: GaDatasetDef + readonly accessToken: string + readonly ingestKey: string + readonly timeZone: string + readonly fromMs: number + readonly toMs: number + readonly now: number + }) { + const { dataset, fromMs, toMs, timeZone } = context + const startDate = utcMsToZonedDate(fromMs, timeZone) + // GA4 date ranges are inclusive on both ends and expressed in property-local dates, so + // the end date is the local date of the last covered instant, not of the exclusive bound. + const endDate = utcMsToZonedDate(toMs - 1, timeZone) + if (startDate === null || endDate === null) return 0 + + const response = yield* runReport({ + accessToken: context.accessToken, + dataBaseUrl, + propertyId: context.row.propertyId, + request: { + dimensions: ["dateHour", ...(dataset.breakdown ? [dataset.breakdown.dimension] : [])], + metrics: dataset.metrics.map((metric) => metric.ga), + startDate, + endDate, + limit: REPORT_ROW_LIMIT, + ...(dataset.breakdown ? { orderByMetric: dataset.breakdown.rankBy } : {}), + }, + }) + + // The date range is whole property-local DAYS, so it necessarily overreaches the + // requested hour window at both ends. Reconciliation must only judge the hours actually + // asked about, hence the explicit covered bounds rather than the response's own extent. + const points = mapReport({ dataset, response, timeZone }) + const ledger = yield* loadLedger(context.orgId, context.row.propertyId, dataset.id, fromMs) + const result = reconcile({ + orgId: context.orgId, + propertyId: context.row.propertyId, + propertyName: context.row.propertyName, + accountName: context.row.accountName, + dataset, + points, + ledger, + coveredFromMs: fromMs, + coveredToMs: toMs, + }) + + const ingested = yield* emitMetrics(context.ingestKey, result.rows) + yield* saveLedger({ + orgId: context.orgId, + propertyId: context.row.propertyId, + dataset: dataset.id, + buckets: result.ledger.filter( + (bucket) => bucket.bucketMs >= fromMs && bucket.bucketMs < toMs, + ), + now: context.now, + }) + return ingested + }) + + /** + * Property discovery, on an hourly TTL. Rows for properties that vanished are soft-disabled + * rather than deleted, so a property that comes back resumes from its old watermark instead + * of re-backfilling a month of history. + */ + const discoverProperties = Effect.fn("GoogleAnalyticsService.discoverProperties")(function* ( + orgId: OrgId, + accessToken: string, + existing: ReadonlyArray, + now: number, + ) { + const properties = yield* listProperties({ accessToken, adminBaseUrl }) + const live = new Set(properties.map((property) => property.propertyId)) + + for (const property of properties) { + for (const dataset of DATASETS) { + yield* upsertRow({ + orgId, + propertyId: property.propertyId, + dataset: dataset.id, + propertyName: property.propertyName, + accountName: property.accountName, + now, + }) + } + } + + const vanished = existing.filter( + (row) => + row.dataset !== DISCOVERY_DATASET && row.enabled && !live.has(row.propertyId), + ) + for (const row of vanished) { + yield* patchRow(row.id, { enabled: false, updatedAt: msToDate(now) }) + } + + yield* dbExecute((db) => + db + .insert(googleAnalyticsState) + .values({ + id: rowId(orgId, DISCOVERY_PROPERTY_ID, DISCOVERY_DATASET), + orgId, + propertyId: DISCOVERY_PROPERTY_ID, + dataset: DISCOVERY_DATASET, + discoveredAt: msToDate(now), + createdAt: msToDate(now), + updatedAt: msToDate(now), + }) + .onConflictDoUpdate({ + target: [ + googleAnalyticsState.orgId, + googleAnalyticsState.propertyId, + googleAnalyticsState.dataset, + ], + set: { discoveredAt: msToDate(now), updatedAt: msToDate(now) }, + }), + ) + + return properties + }) + + /** Resolve and cache a property's reporting timezone. Without it nothing can be polled. */ + const ensureTimeZone = Effect.fn("GoogleAnalyticsService.ensureTimeZone")(function* ( + rows: ReadonlyArray, + accessToken: string, + propertyId: string, + now: number, + ) { + const known = rows.find((row) => row.propertyId === propertyId && row.timeZone != null) + if (known?.timeZone != null) return known.timeZone + const detail = yield* getPropertyTimeZone({ accessToken, adminBaseUrl, propertyId }) + if (detail.timeZone === null) return null + for (const row of rows.filter((candidate) => candidate.propertyId === propertyId)) { + yield* patchRow(row.id, { timeZone: detail.timeZone, updatedAt: msToDate(now) }) + } + return detail.timeZone + }) + + /** + * Which failures end the whole tick rather than just this window. + * + * A dead grant and an exhausted quota are properties of the CONNECTION, not of one report: + * grinding through the remaining (property, dataset) pairs would produce the identical + * failure each time, and in the quota case would spend the org's remaining GA4 budget doing + * it. Everything else is local — a malformed report for one dataset says nothing about the + * next — so it is recorded and the loop continues with that window's frontier untouched. + */ + const isConnectionFatal = (error: unknown) => + error instanceof IntegrationsRevokedError || + (error instanceof IntegrationsUpstreamError && error.status === GA_QUOTA_STATUS) + + /** + * Per-window recovery: re-raise the connection-fatal failures so `pollOrgSafely` can stamp + * the grant or start a quota backoff, and turn everything else into `null` after recording + * it on the row. Swallowing all of them — the obvious `Effect.option` — is what would let a + * dead grant retry forever, silently, on every tick. + */ + const recoverWindow = + (stateRowId: string, now: number) => + ( + effect: Effect.Effect, + ) => + effect.pipe( + Effect.catch((error) => + isConnectionFatal(error) + ? Effect.fail(error) + : patchRow(stateRowId, { + lastError: String(error instanceof Error ? error.message : error).slice(0, 500), + lastErrorAt: msToDate(now), + updatedAt: msToDate(now), + }).pipe( + Effect.ignore, + Effect.as(null), + ), + ), + ) + + const pollOrg = Effect.fn("GoogleAnalyticsService.pollOrg")(function* (orgId: OrgId) { + yield* Effect.annotateCurrentSpan({ orgId }) + const now = yield* Clock.currentTimeMillis + let rowsIngested = 0 + let failures = 0 + let skipped = 0 + const seenProperties = new Set() + + const claimed = yield* claimLease(orgId, now) + // A brand-new connection has no rows yet, so nothing to claim — discovery below creates + // them. Only an org that HAS rows and failed to claim is genuinely busy. + const existingBefore = yield* loadRows(orgId) + if (!claimed && existingBefore.length > 0) { + return { properties: 0, rowsIngested: 0, skipped: 1, failures: 0 } + } + + const { accessToken } = yield* oauth.getValidAccessToken(orgId) + const ingestKey = yield* getOrgIngestKey(orgId) + + const anchor = existingBefore.find((row) => row.dataset === DISCOVERY_DATASET) + const discoveryDue = + anchor?.discoveredAt == null || now - anchor.discoveredAt.getTime() >= DISCOVERY_TTL_MS + let properties: ReadonlyArray = [] + if (discoveryDue) { + properties = yield* discoverProperties(orgId, accessToken, existingBefore, now) + } + + const rows = yield* loadRows(orgId) + const pollable = rows.filter((row) => row.dataset !== DISCOVERY_DATASET && row.enabled) + + // Budget is shared across the org's properties: GA4 meters per property, but a grant + // covering 200 of them would still blow through a tick's wall-clock and the gateway's + // patience without a ceiling here. + let calls = 0 + + for (const row of pollable) { + if (calls >= MAX_CALLS_PER_ORG_TICK) { + skipped += 1 + continue + } + const dataset = DATASETS.find((candidate) => candidate.id === row.dataset) + if (dataset === undefined) continue + + const timeZone = yield* ensureTimeZone(rows, accessToken, row.propertyId, now) + if (timeZone === null) { + skipped += 1 + continue + } + seenProperties.add(row.propertyId) + + const frozenThrough = floorToHour(now - RESTATEMENT_WINDOW_MS) + const head = floorToHour(now) + HOUR_MS + const fullSweepDue = + row.lastSuccessAt == null || now - row.lastSuccessAt.getTime() >= FULL_RECONCILE_INTERVAL_MS + const from = Math.max(frozenThrough, head - (fullSweepDue ? RESTATEMENT_WINDOW_MS : HEAD_WINDOW_MS)) + + calls += 1 + const ingested = yield* pollWindow({ + orgId, + row, + dataset, + accessToken, + ingestKey, + timeZone, + fromMs: from, + toMs: head, + now, + }).pipe(recoverWindow(row.id, now)) + + if (ingested === null) { + failures += 1 + continue + } + rowsIngested += ingested + + yield* patchRow(row.id, { + watermarkAt: msToDate(head), + frozenThroughAt: msToDate(frozenThrough), + backfillAt: row.backfillAt ?? msToDate(from), + lastSuccessAt: msToDate(now), + lastError: null, + lastErrorAt: null, + updatedAt: msToDate(now), + }) + yield* pruneLedger(orgId, row.propertyId, dataset.id, frozenThrough) + + // History fills in behind live data, one round per dataset per tick, and only from + // hours GA4 can no longer revise — a backfilled hour needs no ledger entry. + const backfillTo = row.backfillAt?.getTime() ?? from + const floor = floorToHour(now - BACKFILL_FLOOR_MS) + if (backfillTo > floor && calls < MAX_CALLS_PER_ORG_TICK) { + const backfillFrom = Math.max(floor, backfillTo - BACKFILL_ROUND_MS) + calls += 1 + const backfilled = yield* pollWindow({ + orgId, + row, + dataset, + accessToken, + ingestKey, + timeZone, + fromMs: backfillFrom, + toMs: backfillTo, + now, + }).pipe(recoverWindow(row.id, now)) + if (backfilled !== null) { + rowsIngested += backfilled + yield* patchRow(row.id, { + backfillAt: msToDate(backfillFrom), + updatedAt: msToDate(now), + }) + } + } + } + + return { + properties: seenProperties.size || properties.length, + rowsIngested, + skipped, + failures, + } + }) + + /** + * Wrap one org's poll with the outcomes the cron cares about. A revoked grant stamps the + * connection and stops; a quota rejection holds the lease through a backoff; anything else + * is recorded and retried next tick with the frontiers untouched. + */ + const FAILED_TICK: GoogleAnalyticsPollResult = { + properties: 0, + rowsIngested: 0, + skipped: 0, + failures: 1, + } + + const pollOrgSafely = (orgId: OrgId): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + // Branch on the error VALUE, not on `catchTags`: these failures carry namespaced tags + // ("@maple/http/errors/IntegrationsRevokedError"), so a tag-keyed catch silently + // matches nothing and every case falls through to the generic handler. + return yield* pollOrg(orgId).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (error instanceof IntegrationsRevokedError) { + yield* oauth.markConnectionRevoked(orgId) + yield* recordOrgError(orgId, error.message, now).pipe(Effect.ignore) + return FAILED_TICK + } + if (error instanceof IntegrationsUpstreamError) { + yield* recordOrgError(orgId, error.message, now).pipe(Effect.ignore) + if (error.status === GA_QUOTA_STATUS) { + // Hold the lease rather than clearing it, so the next tick skips this + // org instead of spending the rest of its GA4 budget. + yield* releaseLease(orgId, now + QUOTA_BACKOFF_MS, now).pipe(Effect.ignore) + } + return FAILED_TICK + } + yield* recordOrgError(orgId, String(error), now).pipe(Effect.ignore) + return FAILED_TICK + }), + ), + Effect.catchCause((cause) => + Effect.logError("Google Analytics poll failed", cause).pipe(Effect.as(FAILED_TICK)), + ), + ) + }).pipe( + // The lease is only cleared on a clean finish. A quota backoff has already set its + // own future lease above, and `ensuring` must not undo it. + Effect.tap((result) => + result.failures === 0 + ? Clock.currentTimeMillis.pipe( + Effect.flatMap((now) => releaseLease(orgId, null, now)), + Effect.ignore, + ) + : Effect.void, + ), + ) + + const pollAllOrgs = Effect.fn("GoogleAnalyticsService.pollAllOrgs")(function* () { + const connections = yield* dbExecute((db) => + db + .select({ orgId: oauthConnections.orgId }) + .from(oauthConnections) + .where( + and( + eq(oauthConnections.provider, "google_analytics"), + isNull(oauthConnections.revokedAt), + ), + ), + ).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<{ orgId: OrgId }>)) + + const results = yield* Effect.forEach(connections, (connection) => pollOrgSafely(connection.orgId), { + concurrency: ORG_CONCURRENCY, + }) + + return results.reduce( + (total, result) => ({ + properties: total.properties + result.properties, + rowsIngested: total.rowsIngested + result.rowsIngested, + skipped: total.skipped + result.skipped, + failures: total.failures + result.failures, + }), + { properties: 0, rowsIngested: 0, skipped: 0, failures: 0 }, + ) + }) + + // ── Read surface ────────────────────────────────────────────────────── + + const getIntegrationStatus = Effect.fn("GoogleAnalyticsService.getIntegrationStatus")( + function* (orgId: OrgId) { + const connection = yield* oauth.getStatus(orgId).pipe( + Effect.catch(() => + Effect.succeed({ + connected: false, + connectedAt: null, + externalUserEmail: null, + connectedByUserId: null, + scope: "", + revoked: false, + }), + ), + ) + const rows = yield* loadRows(orgId) + + // One status entry per PROPERTY, not per row: the datasets are an implementation + // detail, so the property's health is the worst of its datasets and its progress the + // least advanced of them. + const byProperty = new Map>() + for (const row of rows) { + if (row.dataset === DISCOVERY_DATASET) continue + const group = byProperty.get(row.propertyId) + if (group === undefined) byProperty.set(row.propertyId, [row]) + else group.push(row) + } + + const properties = [...byProperty.entries()] + .map(([propertyId, group]): GoogleAnalyticsPropertyStatus => { + const withError = group.find((row) => row.lastError != null) + const oldest = (pick: (row: GoogleAnalyticsStateRow) => Date | null) => + group + .map(pick) + .filter((value): value is Date => value != null) + .reduce( + (min, value) => (min === null ? value.getTime() : Math.min(min, value.getTime())), + null, + ) + return { + propertyId, + propertyName: group[0]?.propertyName ?? null, + accountName: group[0]?.accountName ?? null, + timeZone: group[0]?.timeZone ?? null, + enabled: group.some((row) => row.enabled), + lastSyncedAt: oldest((row) => row.lastSuccessAt), + lastError: withError?.lastError ?? null, + watermarkAt: oldest((row) => row.watermarkAt), + backfillAt: oldest((row) => row.backfillAt), + } + }) + .sort((a, b) => a.propertyId.localeCompare(b.propertyId)) + + return { + connected: connection.connected, + connectedAt: connection.connectedAt, + externalUserEmail: connection.externalUserEmail, + revoked: connection.revoked, + properties, + } satisfies GoogleAnalyticsIntegrationStatus + }, + ) + + const setPropertyEnabled = Effect.fn("GoogleAnalyticsService.setPropertyEnabled")(function* ( + orgId: OrgId, + propertyId: string, + enabled: boolean, + ) { + const now = yield* Clock.currentTimeMillis + yield* dbExecute((db) => + db + .update(googleAnalyticsState) + .set({ enabled, updatedAt: msToDate(now) }) + .where( + and( + eq(googleAnalyticsState.orgId, orgId), + eq(googleAnalyticsState.propertyId, propertyId), + ), + ), + ) + }) + + const resetOrgState = Effect.fn("GoogleAnalyticsService.resetOrgState")(function* (orgId: OrgId) { + yield* dbExecute((db) => + db.delete(googleAnalyticsLedger).where(eq(googleAnalyticsLedger.orgId, orgId)), + ) + yield* dbExecute((db) => + db.delete(googleAnalyticsState).where(eq(googleAnalyticsState.orgId, orgId)), + ) + }) + + return { + pollAllOrgs, + pollOrg: (orgId: OrgId) => pollOrgSafely(orgId), + getIntegrationStatus, + setPropertyEnabled, + resetOrgState, + } satisfies GoogleAnalyticsServiceApi + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(FetchHttpClient.layer)) +} diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index 57af79962..f260076ea 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -235,6 +235,7 @@ describe("parity with the pre-refactor per-worker expressions", () => { "GOOGLE_OAUTH_AUTHORIZE_URL", "GOOGLE_OAUTH_TOKEN_URL", "GOOGLE_OAUTH_REVOKE_URL", + "GOOGLE_OAUTH_USERINFO_URL", "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL", "MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL", ] @@ -358,6 +359,7 @@ describe("parity with the pre-refactor per-worker expressions", () => { ...oldOptionalPlain(env, "GOOGLE_OAUTH_AUTHORIZE_URL"), ...oldOptionalPlain(env, "GOOGLE_OAUTH_TOKEN_URL"), ...oldOptionalPlain(env, "GOOGLE_OAUTH_REVOKE_URL"), + ...oldOptionalPlain(env, "GOOGLE_OAUTH_USERINFO_URL"), ...oldOptionalPlain(env, "MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL"), ...oldOptionalPlain(env, "MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL"), } diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index 16d5b5a85..d616957b0 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -285,6 +285,7 @@ export const googleAnalyticsOAuthEnv: Config.Config = merge( optionalPlain("GOOGLE_OAUTH_AUTHORIZE_URL"), optionalPlain("GOOGLE_OAUTH_TOKEN_URL"), optionalPlain("GOOGLE_OAUTH_REVOKE_URL"), + optionalPlain("GOOGLE_OAUTH_USERINFO_URL"), optionalPlain("MAPLE_GOOGLE_ANALYTICS_DATA_API_BASE_URL"), optionalPlain("MAPLE_GOOGLE_ANALYTICS_ADMIN_API_BASE_URL"), ) From 19ced8a4699c4110f8d66ef87dcfa71e04a359d2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 9 Sep 2026 02:07:46 +0200 Subject: [PATCH 03/11] feat(ga4): v2 API surface, OAuth callback, and cron wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public surface is v2 from the start rather than promoted from v1. Cloudflare sits on the older v1 group because it predates v2; PlanetScale and Slack are the two most recent integrations and both live in v2, and the dashboard client is migrating there. `GET /` reports one entry per PROPERTY rather than per state row — the six report types are an implementation detail, so a property's health is the worst of its datasets and its progress the least advanced. `PATCH /properties/{property_id}` toggles collection without discarding position, so re-enabling resumes instead of re-collecting a month of history. `DELETE` drops the collector state along with the grant: leaving the ledger behind would make a later reconnect emit deltas against values describing a connection that no longer exists. The connect flow reuses PlanetScale's trusted-origin gate verbatim. The callback URL is persisted and replayed as `redirect_uri` at token exchange and the origin comes from a client-settable header, so an untrusted one would mint an authorize URL pointing at a host the caller controls. The poll runs on the existing 15-minute cron rather than Cloudflare's 5-minute one: GA4 does not update fast enough to reward a tighter cadence, and every tick spends Data API quota per property. `AllV2GroupLayersLive` refuses to build unless every registered group has a handler layer, so the harnesses that never touch this group get inert stubs — the same treatment PlanetScale gets. --- apps/alerting/src/scheduled.test.ts | 5 +- apps/alerting/src/scheduled.ts | 31 +- apps/api/src/alerting.ts | 2 + apps/api/src/routes/v1/integrations.http.ts | 90 ++++++ .../v2/alchemy-provider.integration.test.ts | 2 + apps/api/src/routes/v2/alerts.http.test.ts | 2 + apps/api/src/routes/v2/api-keys.http.test.ts | 2 + .../routes/v2/config-resources.http.test.ts | 2 + .../api/src/routes/v2/dashboards.http.test.ts | 2 + .../src/routes/v2/integrations.http.test.ts | 2 + apps/api/src/routes/v2/integrations.http.ts | 165 ++++++++++ .../src/routes/v2/mobile-devices.http.test.ts | 2 + .../routes/v2/phase1-resources.http.test.ts | 2 + .../src/routes/v2/setup-audit.http.test.ts | 2 + apps/api/src/routes/v2/telemetry.http.test.ts | 2 + apps/api/src/routes/v2/v2-test-support.ts | 30 +- .../routes/v2/widget-credentials.http.test.ts | 2 + .../src/routes/v2/widget-summary.http.test.ts | 2 + apps/api/src/runtime/graph-boundaries.test.ts | 1 + apps/api/src/runtime/http-graph.ts | 7 +- apps/api/src/runtime/service-graph.ts | 10 + apps/api/src/services/audit/audit-actions.ts | 3 + .../auth/GoogleAnalyticsOAuthService.ts | 6 + packages/domain/src/http/v2/api.ts | 2 + packages/domain/src/http/v2/index.ts | 1 + .../http/v2/integrations-google-analytics.ts | 293 ++++++++++++++++++ packages/domain/src/http/v2/openapi.test.ts | 5 + 27 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 packages/domain/src/http/v2/integrations-google-analytics.ts diff --git a/apps/alerting/src/scheduled.test.ts b/apps/alerting/src/scheduled.test.ts index 1c8029d80..71615972c 100644 --- a/apps/alerting/src/scheduled.test.ts +++ b/apps/alerting/src/scheduled.test.ts @@ -5,7 +5,7 @@ import { buildLayer, catchTickFailure, selectScheduledProgram, type ScheduledTic const cronCases = [ ["*/5 * * * *", ["anomaly", "cloudflareAnalytics", "planetScale"]], - ["*/15 * * * *", ["digest"]], + ["*/15 * * * *", ["digest", "googleAnalytics"]], ["0 * * * *", ["serviceMapRollup"]], ["* * * * *", ["alert", "error", "escalation", "fixVerification"]], ] as const @@ -27,6 +27,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + googleAnalytics: tick("googleAnalytics"), planetScale: tick("planetScale"), serviceMapRollup: tick("serviceMapRollup"), } satisfies ScheduledTickPrograms @@ -56,6 +57,7 @@ describe("alerting Effect root", () => { error: errorGate.await.pipe(Effect.andThen(record("error"))), escalation: record("escalation"), fixVerification: record("fixVerification"), + googleAnalytics: record("googleAnalytics"), planetScale: record("planetScale"), serviceMapRollup: record("serviceMapRollup"), } satisfies ScheduledTickPrograms @@ -81,6 +83,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + googleAnalytics: tick("googleAnalytics"), planetScale: tick("planetScale"), serviceMapRollup: tick("serviceMapRollup"), } satisfies ScheduledTickPrograms diff --git a/apps/alerting/src/scheduled.ts b/apps/alerting/src/scheduled.ts index 0453696e8..3204bef34 100644 --- a/apps/alerting/src/scheduled.ts +++ b/apps/alerting/src/scheduled.ts @@ -27,6 +27,8 @@ import { ErrorsService, EscalationService, FixVerificationTickService, + GoogleAnalyticsOAuthService, + GoogleAnalyticsService, HazelOAuthService, layerPg, NotificationDispatcher, @@ -236,6 +238,12 @@ export const buildLayer = (env: AlertingWorkerEnv) => { ), ) + const GoogleAnalyticsOAuthServiceLive = GoogleAnalyticsOAuthService.layer.pipe(Layer.provide(BaseLive)) + + const GoogleAnalyticsServiceLive = GoogleAnalyticsService.layer.pipe( + Layer.provide(Layer.mergeAll(BaseLive, GoogleAnalyticsOAuthServiceLive, OrgIngestKeysServiceLive)), + ) + const PlanetScaleOAuthServiceLive = PlanetScaleOAuthService.layer.pipe(Layer.provide(BaseLive)) const PlanetScaleServiceLive = PlanetScaleService.layer.pipe( @@ -246,6 +254,7 @@ export const buildLayer = (env: AlertingWorkerEnv) => { AlertsServiceLive, AnomalyDetectionServiceLive, CloudflareAnalyticsServiceLive, + GoogleAnalyticsServiceLive, PlanetScaleServiceLive, DigestServiceLive, ErrorsServiceLive, @@ -420,6 +429,21 @@ const cloudflareAnalyticsTick = makeTick( }), ) +/** + * Runs on the 15-minute cron, not Cloudflare's 5-minute one. GA4 does not update fast enough to + * reward a tighter cadence, and every tick spends Data API quota tokens per property. + */ +const googleAnalyticsTick = makeTick( + GoogleAnalyticsService.use((analytics) => analytics.pollAllOrgs()), + "google_analytics", + (result) => ({ + properties: result.properties, + rowsIngested: result.rowsIngested, + skipped: result.skipped, + failures: result.failures, + }), +) + const planetScaleTick = makeTick( PlanetScaleService.use((planetscale) => planetscale.pollAllOrgs()), "planetscale", @@ -443,6 +467,7 @@ export interface ScheduledTickPrograms { readonly error: Effect.Effect readonly escalation: Effect.Effect readonly fixVerification: Effect.Effect + readonly googleAnalytics: Effect.Effect readonly planetScale: Effect.Effect readonly serviceMapRollup: Effect.Effect } @@ -463,7 +488,9 @@ export const selectScheduledProgram = ( discard: true, }), ), - Match.when("*/15 * * * *", () => ticks.digest), + Match.when("*/15 * * * *", () => + Effect.all([ticks.digest, ticks.googleAnalytics], { concurrency: 2, discard: true }), + ), Match.when("0 * * * *", () => ticks.serviceMapRollup), Match.when("* * * * *", () => // `fixVerification` is chained onto `error` rather than listed beside it: @@ -493,6 +520,7 @@ type ScheduledServices = | ErrorsService | EscalationService | FixVerificationTickService + | GoogleAnalyticsService | PlanetScaleService | ServiceMapRollupService @@ -504,6 +532,7 @@ export const scheduledTicks: ScheduledTickPrograms = { error: errorTick, escalation: escalationTick, fixVerification: fixVerificationTick, + googleAnalytics: googleAnalyticsTick, planetScale: planetScaleTick, serviceMapRollup: serviceMapRollupTick, } diff --git a/apps/api/src/alerting.ts b/apps/api/src/alerting.ts index 0627ac032..74e9fc583 100644 --- a/apps/api/src/alerting.ts +++ b/apps/api/src/alerting.ts @@ -9,6 +9,8 @@ export { BucketCacheService } from "@maple/query-engine/caching" export { CacheBackendLive } from "@/platform/CacheBackendLive" export { CloudflareAnalyticsService } from "./services/integrations/CloudflareAnalyticsService" export { CloudflareOAuthService } from "./services/auth/CloudflareOAuthService" +export { GoogleAnalyticsService } from "./services/integrations/GoogleAnalyticsService" +export { GoogleAnalyticsOAuthService } from "./services/auth/GoogleAnalyticsOAuthService" export { ErrorActorsService } from "./services/errors/ErrorActorsService" export { ErrorIssueReadModelsService } from "./services/errors/ErrorIssueReadModelsService" export { ErrorIssueWorkflowService } from "./services/errors/ErrorIssueWorkflowService" diff --git a/apps/api/src/routes/v1/integrations.http.ts b/apps/api/src/routes/v1/integrations.http.ts index e28b15c28..cd13eb53a 100644 --- a/apps/api/src/routes/v1/integrations.http.ts +++ b/apps/api/src/routes/v1/integrations.http.ts @@ -62,6 +62,11 @@ import { } from "@/services/integrations/cloudflare-analytics/queries" import { PlanetScaleConnectionService } from "@/services/integrations/PlanetScaleConnectionService" import { PlanetScaleService } from "@/services/integrations/PlanetScaleService" +import { + GOOGLE_ANALYTICS_CALLBACK_PATH, + GoogleAnalyticsOAuthService, +} from "@/services/auth/GoogleAnalyticsOAuthService" +import { GoogleAnalyticsService } from "@/services/integrations/GoogleAnalyticsService" import { PLANETSCALE_CALLBACK_PATH, PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService" import { GithubConnectService } from "@/services/integrations/vcs/vendor/github/GithubConnectService" import { VcsCommitService } from "@/services/integrations/vcs/VcsCommitService" @@ -80,6 +85,7 @@ const HAZEL_MESSAGE_TYPE = "maple:integration:hazel" const GITHUB_MESSAGE_TYPE = "maple:integration:github" const CLOUDFLARE_MESSAGE_TYPE = "maple:integration:cloudflare" const PLANETSCALE_MESSAGE_TYPE = "maple:integration:planetscale" +const GOOGLE_ANALYTICS_MESSAGE_TYPE = "maple:integration:google-analytics" /** * How long `cloudflarePrime` spends on the post-connect poll. Long enough for zone discovery plus @@ -831,6 +837,8 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => const cloudflareAnalytics = yield* CloudflareAnalyticsService const planetscaleOAuth = yield* PlanetScaleOAuthService const planetscaleConnection = yield* PlanetScaleConnectionService + const googleAnalyticsOAuth = yield* GoogleAnalyticsOAuthService + const googleAnalytics = yield* GoogleAnalyticsService const env = yield* Env const dashboardTargetOrigin = resolveDashboardTargetOrigin(env.MAPLE_APP_BASE_URL) @@ -855,6 +863,14 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => messageType: CLOUDFLARE_MESSAGE_TYPE, label: "Cloudflare", }) + const googleAnalyticsCallbackPage = (params: Omit) => + renderCallbackPage({ + ...params, + targetOrigin: dashboardTargetOrigin, + messageType: GOOGLE_ANALYTICS_MESSAGE_TYPE, + label: "Google Analytics", + }) + const planetscaleCallbackPage = (params: Omit) => renderCallbackPage({ ...params, @@ -1276,5 +1292,79 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => }) yield* router.add("GET", PLANETSCALE_CALLBACK_PATH, handlePlanetScale) + + const googleAnalyticsErrorPage = (message: string) => + htmlResponse(googleAnalyticsCallbackPage({ status: "error", message, returnTo: null }), 400) + + const handleGoogleAnalytics = Effect.fn("integrations.googleAnalyticsOAuthCallback")(function* ( + req: HttpServerRequest.HttpServerRequest, + ) { + const urlOption = Option.liftThrowable(() => new URL(req.url, "http://localhost"))() + if (Option.isNone(urlOption)) { + return googleAnalyticsErrorPage("Malformed callback URL") + } + const url = urlOption.value + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const oauthError = url.searchParams.get("error") + + if (oauthError) { + // Google's own codes are terse; `access_denied` is the one users actually hit, + // by closing the consent screen. + return googleAnalyticsErrorPage( + oauthError === "access_denied" + ? "Google sign-in was cancelled — the connection wasn't authorized." + : `Google returned an error (${oauthError})`, + ) + } + + if (!code || !state) { + return googleAnalyticsErrorPage("Missing code or state in callback") + } + + return yield* googleAnalyticsOAuth.completeConnect(code, state).pipe( + // The first collection is NOT run here. It takes tens of seconds on a grant with + // several properties, and the popup would sit blank for all of it; the dashboard + // calls `prime` from the tab that stays open instead. + Effect.tap((result) => googleAnalytics.resetOrgState(result.orgId).pipe(Effect.ignore)), + // The callback page reduces failures to short human copy — make sure the real + // cause still lands in the server log for diagnosis. + Effect.tapError((error) => + Effect.logError("Google Analytics OAuth completeConnect failed", { + tag: error._tag, + message: error.message, + }), + ), + Effect.map((result) => + htmlResponse( + googleAnalyticsCallbackPage({ + status: "success", + message: "Google Analytics connected. You can close this window and return to Maple.", + returnTo: result.returnTo, + }), + ), + ), + Effect.catchTags({ + // Validation/upstream messages are our own sanitized strings — and for this + // provider they carry the two refusals a user can actually act on: a grant + // with no refresh token, and one that reaches no GA4 property. + "@maple/http/errors/IntegrationsValidationError": (error) => + Effect.succeed(googleAnalyticsErrorPage(error.message)), + "@maple/http/errors/IntegrationsUpstreamError": (error) => + Effect.succeed(googleAnalyticsErrorPage(error.message)), + "@maple/http/errors/IntegrationsRevokedError": () => + Effect.succeed( + googleAnalyticsErrorPage( + "Google rejected the authorization — reconnect and try again", + ), + ), + "@maple/http/errors/IntegrationsPersistenceError": () => + Effect.succeed(googleAnalyticsErrorPage("Failed to complete Google Analytics connection")), + }), + ) + }) + + yield* router.add("GET", GOOGLE_ANALYTICS_CALLBACK_PATH, handleGoogleAnalytics) + }), ) diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts index ef5ce1e97..c5b220f21 100644 --- a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -51,6 +51,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -176,6 +177,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 4e84f09ad..fcdee602f 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -37,6 +37,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -164,6 +165,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), diff --git a/apps/api/src/routes/v2/api-keys.http.test.ts b/apps/api/src/routes/v2/api-keys.http.test.ts index 2b2500e84..bf50b30d5 100644 --- a/apps/api/src/routes/v2/api-keys.http.test.ts +++ b/apps/api/src/routes/v2/api-keys.http.test.ts @@ -19,6 +19,7 @@ import { AlertsServiceStubLayer, AllV2GroupLayersLive, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -64,6 +65,7 @@ const makeHarness = (checkRateLimit: RateLimiterApi["check"] = () => Effect.succ Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/config-resources.http.test.ts b/apps/api/src/routes/v2/config-resources.http.test.ts index 1daca7a6c..9d4415c26 100644 --- a/apps/api/src/routes/v2/config-resources.http.test.ts +++ b/apps/api/src/routes/v2/config-resources.http.test.ts @@ -27,6 +27,7 @@ import { ApiV2RateLimiterAllowAllLayer, makeWarehouseServiceStub, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, SetupAuditServiceStubLayer, @@ -109,6 +110,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SetupAuditServiceStubLayer), diff --git a/apps/api/src/routes/v2/dashboards.http.test.ts b/apps/api/src/routes/v2/dashboards.http.test.ts index 2a414c511..8f098fed4 100644 --- a/apps/api/src/routes/v2/dashboards.http.test.ts +++ b/apps/api/src/routes/v2/dashboards.http.test.ts @@ -18,6 +18,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -62,6 +63,7 @@ const makeHarness = () => { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/integrations.http.test.ts b/apps/api/src/routes/v2/integrations.http.test.ts index e9d04f3a3..1a14f73cf 100644 --- a/apps/api/src/routes/v2/integrations.http.test.ts +++ b/apps/api/src/routes/v2/integrations.http.test.ts @@ -46,6 +46,7 @@ import { AllV2GroupLayersLive, ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, + GoogleAnalyticsServiceStubsLayer, TelemetryServiceStubsLayer, } from "./v2-test-support" @@ -167,6 +168,7 @@ const makeHarness = (slack: Partial = {}, planetscal Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(slackServiceLayer(slack)), Layer.provide(planetscaleServiceLayer(planetscale)), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/integrations.http.ts b/apps/api/src/routes/v2/integrations.http.ts index 4dfbc1c1f..1d2053253 100644 --- a/apps/api/src/routes/v2/integrations.http.ts +++ b/apps/api/src/routes/v2/integrations.http.ts @@ -8,6 +8,10 @@ import type { import { CurrentTenant } from "@maple/domain/http" import type { PlanetScaleDatabaseRow } from "@maple/db" import type { + V2GoogleAnalyticsConnectResponse, + V2GoogleAnalyticsDisconnectResponse, + V2GoogleAnalyticsIntegration, + V2GoogleAnalyticsPrimeResponse, V2PlanetScaleConnectResponse, V2PlanetScaleDatabase, V2PlanetScaleDatabaseList, @@ -35,12 +39,24 @@ import { recordHttpAudit } from "@/services/audit/AuditLogService" import { requireAdmin } from "@/services/auth/auth" import { Env } from "@/platform/Env" import { EdgeCacheService } from "@maple/cache" +import { + GOOGLE_ANALYTICS_CALLBACK_PATH, + GoogleAnalyticsOAuthService, +} from "@/services/auth/GoogleAnalyticsOAuthService" +import type { GoogleAnalyticsIntegrationStatus } from "@/services/integrations/GoogleAnalyticsService" +import { GoogleAnalyticsService } from "@/services/integrations/GoogleAnalyticsService" import { PLANETSCALE_CALLBACK_PATH, PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService" import { PlanetScaleConnectionService } from "@/services/integrations/PlanetScaleConnectionService" import { PlanetScaleService } from "@/services/integrations/PlanetScaleService" import type { SlackChannelList, SlackInstallStatus } from "@/services/integrations/SlackIntegrationService" import { SLACK_CALLBACK_PATH, SlackIntegrationService } from "@/services/integrations/SlackIntegrationService" +/** + * How long `prime` spends on the post-connect poll. Long enough for property discovery plus a + * first window on an ordinary grant; a many-propertied one resumes on the next cron tick. + */ +const GOOGLE_ANALYTICS_PRIME_TIMEOUT = "20 seconds" + /** * Best-effort origin of the incoming request. `x-forwarded-*` is client-supplied * on any path that does not strip it, so the result is NOT trusted on its own — @@ -587,3 +603,152 @@ export const HttpV2PlanetScaleIntegrationsLive = HttpApiBuilder.group( ) }), ) + +/** + * Google Analytics 4. Reads are open to any org member; every mutation is admin-gated, matching + * the other integrations. The connect flow is the same trusted-origin dance as PlanetScale: the + * callback URL is persisted and replayed as `redirect_uri` at token exchange, and the origin is + * derived from a client-settable header, so an untrusted one would mint an authorize URL pointing + * at a host the caller controls. + */ +export const HttpV2GoogleAnalyticsIntegrationsLive = HttpApiBuilder.group( + MapleApiV2, + "googleAnalyticsIntegration", + (handlers) => + Effect.gen(function* () { + const analytics = yield* GoogleAnalyticsService + const googleOAuth = yield* GoogleAnalyticsOAuthService + const env = yield* Env + + const toStatus = (status: GoogleAnalyticsIntegrationStatus): V2GoogleAnalyticsIntegration => ({ + object: "google_analytics_integration" as const, + connected: status.connected, + connected_at: isoTimestampOrNull(status.connectedAt), + connected_email: status.externalUserEmail, + revoked: status.revoked, + properties: status.properties.map((property) => ({ + object: "google_analytics_property" as const, + property_id: property.propertyId, + property_name: property.propertyName, + account_name: property.accountName, + time_zone: property.timeZone, + enabled: property.enabled, + last_synced_at: isoTimestampOrNull(property.lastSyncedAt), + last_error: property.lastError, + watermark_at: isoTimestampOrNull(property.watermarkAt), + backfill_at: isoTimestampOrNull(property.backfillAt), + })), + }) + + return handlers + .handle("status", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const status = yield* analytics + .getIntegrationStatus(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics status failed")) + return toStatus(status) + }), + ) + .handle("connect", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can connect Google Analytics"), + ) + const req = yield* HttpServerRequest.HttpServerRequest + const origin = resolveRequestOrigin(req) + if (!isTrustedCallbackOrigin(origin, env.MAPLE_APP_BASE_URL)) { + yield* Effect.logError( + "Rejected Google Analytics connect: untrusted callback origin", + { origin }, + ) + return yield* Effect.fail( + V2CallbackHostUnavailable.make( + "Google Analytics connections are not available from this host", + ), + ) + } + const result = yield* googleOAuth + .startConnect(tenant.orgId, tenant.userId, { + callbackUrl: `${origin}${GOOGLE_ANALYTICS_CALLBACK_PATH}`, + returnTo: payload.return_to, + }) + .pipe(tapHttpErrors("Google Analytics connect failed")) + yield* recordHttpAudit("google_analytics_integration.connect_started") + return { + object: "google_analytics_integration.connect" as const, + redirect_url: result.redirectUrl, + state: result.state, + } satisfies V2GoogleAnalyticsConnectResponse + }), + ) + .handle("disconnect", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can disconnect Google Analytics"), + ) + const result = yield* googleOAuth + .disconnect(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics disconnect failed")) + // Collector state goes with the grant: leaving it would make a later + // reconnect resume against a ledger describing a connection that no longer + // exists, and emit deltas against values nobody can verify. + yield* analytics.resetOrgState(tenant.orgId).pipe(Effect.ignore) + yield* recordHttpAudit("google_analytics_integration.disconnected") + return { + object: "google_analytics_integration.disconnect" as const, + disconnected: result.disconnected, + } satisfies V2GoogleAnalyticsDisconnectResponse + }), + ) + .handle("prime", () => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make("Only org admins can run a Google Analytics sync"), + ) + // Bounded: discovery plus a first window on an ordinary account fits well + // inside this, and whatever a many-propertied grant does not finish simply + // resumes on the next cron tick. + const result = yield* analytics + .pollOrg(tenant.orgId) + .pipe(Effect.timeoutOption(GOOGLE_ANALYTICS_PRIME_TIMEOUT)) + return { + object: "google_analytics_integration.prime" as const, + properties: Option.match(result, { + onNone: () => 0, + onSome: (value) => value.properties, + }), + rows_ingested: Option.match(result, { + onNone: () => 0, + onSome: (value) => value.rowsIngested, + }), + } satisfies V2GoogleAnalyticsPrimeResponse + }), + ) + .handle("updateProperty", ({ params, payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* requireAdmin(tenant.roles, () => + V2InsufficientPermissions.make( + "Only org admins can change Google Analytics collection", + ), + ) + yield* analytics + .setPropertyEnabled(tenant.orgId, params.property_id, payload.enabled) + .pipe(tapHttpErrors("Google Analytics property update failed")) + yield* recordHttpAudit( + payload.enabled + ? "google_analytics_integration.property_enabled" + : "google_analytics_integration.property_disabled", + ) + const status = yield* analytics + .getIntegrationStatus(tenant.orgId) + .pipe(tapHttpErrors("Google Analytics status failed")) + return toStatus(status) + }), + ) + }), +) diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 86b2c14a1..6bb9ea749 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -21,6 +21,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -78,6 +79,7 @@ const makeHarness = () => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index 12771c1bb..be696dbdd 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -67,6 +67,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -559,6 +560,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), diff --git a/apps/api/src/routes/v2/setup-audit.http.test.ts b/apps/api/src/routes/v2/setup-audit.http.test.ts index e3c6a7247..a44341f46 100644 --- a/apps/api/src/routes/v2/setup-audit.http.test.ts +++ b/apps/api/src/routes/v2/setup-audit.http.test.ts @@ -29,6 +29,7 @@ import { ApiV2RateLimiterAllowAllLayer, makeWarehouseServiceStub, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -140,6 +141,7 @@ const makeHarness = (warehouse: WarehouseQueryServiceApi = warehouseStub()) => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provide(warehouseLive), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index b29bc9763..80bf560c0 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -27,6 +27,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, } from "./v2-test-support" @@ -273,6 +274,7 @@ const makeHarness = ( Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 27c3fd826..0395973da 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -17,6 +17,8 @@ import { OrgIngestKeysService } from "@/services/org/OrgIngestKeysService" import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { PlanetScaleConnectionService } from "@/services/integrations/PlanetScaleConnectionService" import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService" +import { GoogleAnalyticsOAuthService } from "@/services/auth/GoogleAnalyticsOAuthService" +import { GoogleAnalyticsService } from "@/services/integrations/GoogleAnalyticsService" import { PlanetScaleService } from "@/services/integrations/PlanetScaleService" import { ScrapeTargetsService } from "@/services/integrations/ScrapeTargetsService" import { SlackIntegrationService } from "@/services/integrations/SlackIntegrationService" @@ -35,7 +37,11 @@ import { HttpV2ApiKeysLive } from "./api-keys.http" import { HttpV2AttributeMappingsLive } from "./attribute-mappings.http" import { HttpV2DashboardsLive } from "./dashboards.http" import { HttpV2IngestKeysLive } from "./ingest-keys.http" -import { HttpV2PlanetScaleIntegrationsLive, HttpV2SlackIntegrationsLive } from "./integrations.http" +import { + HttpV2GoogleAnalyticsIntegrationsLive, + HttpV2PlanetScaleIntegrationsLive, + HttpV2SlackIntegrationsLive, +} from "./integrations.http" import { HttpV2ErrorIssuesLive } from "./error-issues.http" import { HttpV2AnomaliesLive } from "./anomalies.http" import { HttpV2InvestigationsLive } from "./investigations.http" @@ -81,6 +87,7 @@ export const AllV2GroupLayersLive = Layer.mergeAll( HttpV2ApiKeysLive, HttpV2SlackIntegrationsLive, HttpV2PlanetScaleIntegrationsLive, + HttpV2GoogleAnalyticsIntegrationsLive, HttpV2DashboardsLive, HttpV2AlertDeliveriesLive, HttpV2AlertRulesLive, @@ -323,6 +330,27 @@ export const PlanetScaleServiceStubsLayer = Layer.mergeAll( EdgeCacheService.layer.pipe(Layer.provide(MemoryCacheBackendLive)), ) +/** + * Inert Google Analytics services for harnesses that never touch that integration group. + */ +export const GoogleAnalyticsServiceStubsLayer = Layer.mergeAll( + Layer.succeed(GoogleAnalyticsService, { + pollAllOrgs: die, + pollOrg: die, + getIntegrationStatus: die, + setPropertyEnabled: die, + resetOrgState: die, + }), + Layer.succeed(GoogleAnalyticsOAuthService, { + startConnect: die, + completeConnect: die, + getStatus: die, + getValidAccessToken: die, + disconnect: die, + markConnectionRevoked: die, + }), +) + /** Inert SlackIntegrationService for harnesses that never touch the slack integration group. */ export const SlackIntegrationServiceStubLayer = Layer.succeed( SlackIntegrationService, diff --git a/apps/api/src/routes/v2/widget-credentials.http.test.ts b/apps/api/src/routes/v2/widget-credentials.http.test.ts index b785222b2..e07e92bac 100644 --- a/apps/api/src/routes/v2/widget-credentials.http.test.ts +++ b/apps/api/src/routes/v2/widget-credentials.http.test.ts @@ -21,6 +21,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, Phase1ResourceStubsLayer, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, TelemetryServiceStubsLayer, @@ -76,6 +77,7 @@ const makeHarness = () => { Layer.provide(Phase1ResourceStubsLayer), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(TelemetryServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), Layer.provideMerge(AuditLogService.layerMemory), diff --git a/apps/api/src/routes/v2/widget-summary.http.test.ts b/apps/api/src/routes/v2/widget-summary.http.test.ts index fc25fb99b..6e9b83d73 100644 --- a/apps/api/src/routes/v2/widget-summary.http.test.ts +++ b/apps/api/src/routes/v2/widget-summary.http.test.ts @@ -32,6 +32,7 @@ import { ApiV2RateLimiterAllowAllLayer, ConfigResourceServiceStubsLayer, makeWarehouseServiceStub, + GoogleAnalyticsServiceStubsLayer, PlanetScaleServiceStubsLayer, SlackIntegrationServiceStubLayer, } from "./v2-test-support" @@ -188,6 +189,7 @@ const makeHarness = (options: { Layer.provide(V2TransportErrorBoundaryLive), Layer.provide(SlackIntegrationServiceStubLayer), Layer.provide(PlanetScaleServiceStubsLayer), + Layer.provide(GoogleAnalyticsServiceStubsLayer), Layer.provide(AlertsServiceStubLayer), Layer.provide(ConfigResourceServiceStubsLayer), Layer.provideMerge(ApiAuthorizationV2Layer), diff --git a/apps/api/src/runtime/graph-boundaries.test.ts b/apps/api/src/runtime/graph-boundaries.test.ts index 6757e8c0e..d715901f2 100644 --- a/apps/api/src/runtime/graph-boundaries.test.ts +++ b/apps/api/src/runtime/graph-boundaries.test.ts @@ -92,6 +92,7 @@ describe("API runtime graph boundaries", () => { for (const routeOnlyService of [ "DailySpendService", "CloudflareAnalyticsService", + "GoogleAnalyticsService", "AnomalyDetectionService", "AiTriageService", "DigestService", diff --git a/apps/api/src/runtime/http-graph.ts b/apps/api/src/runtime/http-graph.ts index 7d57123b8..18891b8c8 100644 --- a/apps/api/src/runtime/http-graph.ts +++ b/apps/api/src/runtime/http-graph.ts @@ -44,7 +44,11 @@ import { HttpV2DashboardsLive } from "@/routes/v2/dashboards.http" import { V2TransportErrorBoundaryLive } from "@/routes/v2/error-envelope" import { HttpV2ErrorIssuesLive } from "@/routes/v2/error-issues.http" import { HttpV2IngestKeysLive } from "@/routes/v2/ingest-keys.http" -import { HttpV2PlanetScaleIntegrationsLive, HttpV2SlackIntegrationsLive } from "@/routes/v2/integrations.http" +import { + HttpV2GoogleAnalyticsIntegrationsLive, + HttpV2PlanetScaleIntegrationsLive, + HttpV2SlackIntegrationsLive, +} from "@/routes/v2/integrations.http" import { HttpV2InvestigationsLive } from "@/routes/v2/investigations.http" import { HttpV2MobileDevicesLive } from "@/routes/v2/mobile-devices.http" import { HttpV2OrganizationLive } from "@/routes/v2/organization.http" @@ -136,6 +140,7 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2IngestKeysLive, HttpV2SlackIntegrationsLive, HttpV2PlanetScaleIntegrationsLive, + HttpV2GoogleAnalyticsIntegrationsLive, HttpV2ErrorIssuesLive, HttpV2AttributeMappingsLive, HttpV2AuditLogLive, diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index 588144fae..d8647fea9 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -14,6 +14,7 @@ import { PlanetScaleOAuthService } from "@/services/auth/PlanetScaleOAuthService import { AuthService } from "@/services/auth/AuthService" import { CliDeviceAuthService } from "@/services/auth/CliDeviceAuthService" import { CloudflareOAuthService } from "@/services/auth/CloudflareOAuthService" +import { GoogleAnalyticsOAuthService } from "@/services/auth/GoogleAnalyticsOAuthService" import { HazelOAuthService } from "@/services/auth/HazelOAuthService" import { McpOAuthService } from "@/services/auth/McpOAuthService" import { OAuthStateRepository } from "@/services/auth/OAuthStateRepository" @@ -35,6 +36,7 @@ import { ErrorsService } from "@/services/errors/ErrorsService" import { InvestigationService } from "@/services/errors/InvestigationService" import { RecommendationIssueService } from "@/services/errors/RecommendationIssueService" import { CloudflareAnalyticsService } from "@/services/integrations/CloudflareAnalyticsService" +import { GoogleAnalyticsService } from "@/services/integrations/GoogleAnalyticsService" import { PlanetScaleConnectionService } from "@/services/integrations/PlanetScaleConnectionService" import { PlanetScaleDiscoveryService } from "@/services/integrations/PlanetScaleDiscoveryService" import { PlanetScaleService } from "@/services/integrations/PlanetScaleService" @@ -81,6 +83,7 @@ const CoreServicesLive = Layer.mergeAll( CliDeviceAuthService.layer, McpOAuthService.layer, CloudflareOAuthService.layer, + GoogleAnalyticsOAuthService.layer, DashboardPersistenceService.layer, SharedDashboardService.layer, HazelOAuthService.layer, @@ -117,6 +120,12 @@ const CloudflareAnalyticsServiceLive = CloudflareAnalyticsService.layer.pipe( Layer.provideMerge(Layer.mergeAll(CoreServicesLive, WarehouseQueryServiceLive)), ) +// Serves the integration card's per-property collection status; the poll loop +// itself runs in the alerting worker's cron, not here. +const GoogleAnalyticsServiceLive = GoogleAnalyticsService.layer.pipe( + Layer.provideMerge(CoreServicesLive), +) + const DemoServiceLive = DemoService.layer.pipe( Layer.provideMerge(Layer.mergeAll(CoreServicesLive, WarehouseQueryServiceLive)), ) @@ -293,6 +302,7 @@ const MainServicesLive = Layer.mergeAll( ProductEventsServiceLive, DailySpendServiceLive, CloudflareAnalyticsServiceLive, + GoogleAnalyticsServiceLive, AuditLogServiceLive, WarehouseQueryServiceLive, EdgeCacheServiceLive, diff --git a/apps/api/src/services/audit/audit-actions.ts b/apps/api/src/services/audit/audit-actions.ts index 2e9c1e1e5..6eb7f7b80 100644 --- a/apps/api/src/services/audit/audit-actions.ts +++ b/apps/api/src/services/audit/audit-actions.ts @@ -57,6 +57,9 @@ export const AuditResources = { planetscale_integration: { verbs: ["connect_started", "organization_selected", "metrics_token_set", "disconnected"], }, + google_analytics_integration: { + verbs: ["connect_started", "disconnected", "property_enabled", "property_disabled"], + }, slack_integration: { verbs: ["install_started", "uninstalled"] }, /** * Org membership, learned from Clerk's webhook — the web app changes members diff --git a/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts index b01bb5459..b4b9eb9ca 100644 --- a/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts +++ b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts @@ -33,6 +33,12 @@ import { makeOAuthConnectionHelpers, OAUTH_STATE_TTL_MS } from "./oauth/connecti const GOOGLE_ANALYTICS_PROVIDER = "google_analytics" +/** + * Must match a redirect URI registered on the Google OAuth client exactly, path included — + * Google rejects the exchange otherwise. + */ +export const GOOGLE_ANALYTICS_CALLBACK_PATH = "/api/integrations/google-analytics/callback" + const decodeOrgId = Schema.decodeUnknownSync(OrgId) const UserInfo = Schema.Struct({ diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index e0814e060..b0fc85f64 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -10,6 +10,7 @@ import { V2AuditLogApiGroup } from "./audit-log" import { V2DashboardsApiGroup } from "./dashboards" import { V2IngestKeysApiGroup } from "./ingest-keys" import { V2SlackIntegrationsApiGroup } from "./integrations" +import { V2GoogleAnalyticsIntegrationsApiGroup } from "./integrations-google-analytics" import { V2PlanetScaleIntegrationsApiGroup } from "./integrations-planetscale" import { V2ErrorIssuesApiGroup } from "./error-issues" import { V2InvestigationsApiGroup } from "./investigations" @@ -93,6 +94,7 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2IngestKeysApiGroup) .add(V2SlackIntegrationsApiGroup) .add(V2PlanetScaleIntegrationsApiGroup) + .add(V2GoogleAnalyticsIntegrationsApiGroup) .add(V2ErrorIssuesApiGroup) .add(V2AttributeMappingsApiGroup) .add(V2AuditLogApiGroup) diff --git a/packages/domain/src/http/v2/index.ts b/packages/domain/src/http/v2/index.ts index b04facc3b..4b4a0a4d4 100644 --- a/packages/domain/src/http/v2/index.ts +++ b/packages/domain/src/http/v2/index.ts @@ -14,6 +14,7 @@ export * from "./errors" export * from "./error-issues" export * from "./ingest-keys" export * from "./integrations" +export * from "./integrations-google-analytics" export * from "./integrations-planetscale" export * from "./investigations" export * from "./mobile-devices" diff --git a/packages/domain/src/http/v2/integrations-google-analytics.ts b/packages/domain/src/http/v2/integrations-google-analytics.ts new file mode 100644 index 000000000..ffbb70908 --- /dev/null +++ b/packages/domain/src/http/v2/integrations-google-analytics.ts @@ -0,0 +1,293 @@ +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Schema } from "effect" +import { + IntegrationReturnPath, + IntegrationsNotConnectedError, + IntegrationsPersistenceError, + IntegrationsRevokedError, + IntegrationsUpstreamError, + IntegrationsValidationError, +} from "../integrations" +import { AuthorizationV2 } from "./auth" +import { Timestamp, wireExample } from "./envelopes" +import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" +import { publicErrors } from "./public-error" + +// Google Analytics 4 integration. An org connects a Google account over OAuth, +// Maple discovers every GA4 property that grant can see, and a cron collects each +// property's hourly numbers into the regular OTel metrics pipeline. +// +// v2 from the start rather than promoted from v1: PlanetScale and Slack are the +// most recent integrations and both live here, and the dashboard is migrating +// off the v1 client. + +export const V2GoogleAnalyticsProperty = Schema.Struct({ + object: Schema.Literal("google_analytics_property").annotate({ + description: 'The object type — always `"google_analytics_property"`.', + examples: ["google_analytics_property"], + }), + property_id: Schema.String.annotate({ + description: "The GA4 property ID, without the API's `properties/` resource prefix.", + examples: ["123456789"], + }), + property_name: Schema.NullOr(Schema.String).annotate({ + description: "The property's display name in Google Analytics.", + examples: ["example.com — GA4"], + }), + account_name: Schema.NullOr(Schema.String).annotate({ + description: "The Google Analytics account the property belongs to.", + examples: ["Acme Inc"], + }), + time_zone: Schema.NullOr(Schema.String).annotate({ + description: + "The property's IANA reporting timezone. Google reports hourly data in this zone, so Maple resolves it before collecting anything; `null` means it has not been resolved yet and the property is not being collected.", + examples: ["America/Los_Angeles"], + }), + enabled: Schema.Boolean.annotate({ + description: "Whether Maple collects this property. Newly discovered properties start enabled.", + examples: [true], + }), + last_synced_at: Schema.NullOr(Timestamp).annotate({ + description: "When collection last succeeded for every one of this property's report types.", + }), + last_error: Schema.NullOr(Schema.String).annotate({ + description: "The most recent collection error for this property, if any.", + examples: ["Google Analytics Data API quota exhausted (429 RESOURCE_EXHAUSTED)"], + }), + watermark_at: Schema.NullOr(Timestamp).annotate({ + description: "End of the newest hour collected.", + }), + backfill_at: Schema.NullOr(Timestamp).annotate({ + description: + "Oldest hour the history backfill has reached, walking backwards. Backfill is complete once this stops moving.", + }), +}).annotate({ + identifier: "GoogleAnalyticsProperty", + title: "Google Analytics property", + description: "One GA4 property discovered under the connected Google account.", +}) +export type V2GoogleAnalyticsProperty = Schema.Schema.Type + +export const V2GoogleAnalyticsIntegration = Schema.Struct({ + object: Schema.Literal("google_analytics_integration").annotate({ + description: 'The object type — always `"google_analytics_integration"`.', + examples: ["google_analytics_integration"], + }), + connected: Schema.Boolean.annotate({ + description: "Whether a Google account is currently connected.", + examples: [true], + }), + connected_at: Schema.NullOr(Timestamp).annotate({ + description: "When the connection was established.", + }), + connected_email: Schema.NullOr(Schema.String).annotate({ + description: "The Google account the grant belongs to.", + examples: ["analytics@example.com"], + }), + revoked: Schema.Boolean.annotate({ + description: + "True when Google rejected the stored grant. Collection stops until someone reconnects; nothing already collected is lost.", + examples: [false], + }), + properties: Schema.Array(V2GoogleAnalyticsProperty).annotate({ + description: "Every GA4 property discovered under the grant, ordered by property ID.", + }), +}).annotate({ + identifier: "GoogleAnalyticsIntegration", + title: "Google Analytics integration", + description: "The Google Analytics connection state for your organization.", +}) +export type V2GoogleAnalyticsIntegration = Schema.Schema.Type + +export const V2GoogleAnalyticsConnectRequest = Schema.Struct({ + return_to: Schema.optionalKey(IntegrationReturnPath).annotate({ + description: + "Relative path in the Maple dashboard to send the user back to after the callback completes — absolute URLs are rejected. Ignored for headless callers.", + }), +}).annotate({ + identifier: "GoogleAnalyticsConnectRequest", + title: "Google Analytics connect request", + description: "Options for beginning a Google OAuth authorization.", + examples: [wireExample({ return_to: "/integrations" })], +}) +export type V2GoogleAnalyticsConnectRequest = Schema.Schema.Type + +export const V2GoogleAnalyticsConnectResponse = Schema.Struct({ + object: Schema.Literal("google_analytics_integration.connect").annotate({ + description: 'The object type — always `"google_analytics_integration.connect"`.', + examples: ["google_analytics_integration.connect"], + }), + redirect_url: Schema.String.annotate({ + description: + "The Google authorize URL to send the user to. Opens Google's consent screen; on approval Google redirects back to Maple's callback.", + examples: ["https://accounts.google.com/o/oauth2/v2/auth?client_id=..."], + }), + state: Schema.String.annotate({ + description: "The opaque OAuth state parameter embedded in `redirect_url`.", + examples: ["b4f1c0e2"], + }), +}).annotate({ + identifier: "GoogleAnalyticsConnectResponse", + title: "Google Analytics connect response", + description: "Where to send the user to authorize Google Analytics.", +}) +export type V2GoogleAnalyticsConnectResponse = Schema.Schema.Type + +export const V2GoogleAnalyticsDisconnectResponse = Schema.Struct({ + object: Schema.Literal("google_analytics_integration.disconnect").annotate({ + description: 'The object type — always `"google_analytics_integration.disconnect"`.', + examples: ["google_analytics_integration.disconnect"], + }), + disconnected: Schema.Boolean.annotate({ + description: "True when a connection was removed; false when there was nothing to remove.", + examples: [true], + }), +}).annotate({ + identifier: "GoogleAnalyticsDisconnectResponse", + title: "Google Analytics disconnect response", +}) +export type V2GoogleAnalyticsDisconnectResponse = Schema.Schema.Type< + typeof V2GoogleAnalyticsDisconnectResponse +> + +export const V2GoogleAnalyticsPrimeResponse = Schema.Struct({ + object: Schema.Literal("google_analytics_integration.prime").annotate({ + description: 'The object type — always `"google_analytics_integration.prime"`.', + examples: ["google_analytics_integration.prime"], + }), + properties: Schema.Number.annotate({ + description: "How many properties the poll touched.", + examples: [2], + }), + rows_ingested: Schema.Number.annotate({ + description: "How many metric data points the poll wrote.", + examples: [412], + }), +}).annotate({ + identifier: "GoogleAnalyticsPrimeResponse", + title: "Google Analytics prime response", + description: "Result of the bounded first collection run after connecting.", +}) +export type V2GoogleAnalyticsPrimeResponse = Schema.Schema.Type + +export const V2GoogleAnalyticsPropertyUpdateParams = Schema.Struct({ + enabled: Schema.Boolean.annotate({ + description: "Whether Maple should collect this property.", + examples: [false], + }), +}).annotate({ + identifier: "GoogleAnalyticsPropertyUpdateParams", + title: "Google Analytics property update", + examples: [wireExample({ enabled: false })], +}) +export type V2GoogleAnalyticsPropertyUpdateParams = Schema.Schema.Type< + typeof V2GoogleAnalyticsPropertyUpdateParams +> + +const [ + integrationNotConnected, + integrationRevoked, + integrationValidation, + integrationUpstream, + integrationPersistence, +] = publicErrors( + IntegrationsNotConnectedError, + IntegrationsRevokedError, + IntegrationsValidationError, + IntegrationsUpstreamError, + IntegrationsPersistenceError, +) + +const connectionErrors = [ + integrationNotConnected, + integrationRevoked, + integrationValidation, + integrationUpstream, + integrationPersistence, +] as const + +export class V2GoogleAnalyticsIntegrationsApiGroup extends HttpApiGroup.make("googleAnalyticsIntegration") + .add( + HttpApiEndpoint.get("status", "/", { + success: V2GoogleAnalyticsIntegration, + error: [integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "getGoogleAnalyticsIntegration", + summary: "Retrieve Google Analytics integration status", + description: + "Returns the Google Analytics connection state for your organization and the collection health of every GA4 property under the grant. Requires the `integrations:read` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("connect", "/connect", { + payload: V2GoogleAnalyticsConnectRequest, + // No upstream error: nothing reaches Google until the browser follows + // the returned authorize URL. + success: V2GoogleAnalyticsConnectResponse, + error: [ + V2InsufficientPermissions.schema, + V2CallbackHostUnavailable.schema, + integrationValidation, + integrationPersistence, + ], + }).annotateMerge( + OpenApi.annotations({ + identifier: "connectGoogleAnalyticsIntegration", + summary: "Begin a Google Analytics connection", + description: + "Returns a Google OAuth authorize URL to redirect the user to. Browser-oriented: a headless caller cannot complete the redirect, so scripted setups should connect once from the dashboard. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("disconnect", "/", { + success: V2GoogleAnalyticsDisconnectResponse, + error: [V2InsufficientPermissions.schema, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "disconnectGoogleAnalyticsIntegration", + summary: "Disconnect Google Analytics", + description: + "Revokes the grant at Google and removes the connection along with all collection state. Metrics already collected are retained and age out with your normal retention. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prime", "/prime", { + success: V2GoogleAnalyticsPrimeResponse, + error: [V2InsufficientPermissions.schema, ...connectionErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "primeGoogleAnalyticsIntegration", + summary: "Run a Google Analytics collection now", + description: + "Runs one bounded collection pass immediately instead of waiting for the next scheduled run, so a freshly connected property shows data right away. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .add( + HttpApiEndpoint.patch("updateProperty", "/properties/:property_id", { + params: { property_id: Schema.String }, + payload: V2GoogleAnalyticsPropertyUpdateParams, + success: V2GoogleAnalyticsIntegration, + error: [V2InsufficientPermissions.schema, integrationPersistence], + }).annotateMerge( + OpenApi.annotations({ + identifier: "updateGoogleAnalyticsProperty", + summary: "Enable or disable a Google Analytics property", + description: + "Turns collection on or off for one GA4 property. Disabling stops collection but keeps the property's position, so re-enabling resumes from where it left off rather than re-collecting history. Requires an org-admin role and the `integrations:write` scope.", + }), + ), + ) + .prefix("/v2/integrations/google_analytics") + .middleware(AuthorizationV2) + .annotateMerge( + OpenApi.annotations({ + title: "Google Analytics Integration", + description: + "Connect Google Analytics 4 to your organization and manage what Maple collects from it: connection status, the properties discovered under the grant, and which of them are collected. Collected data lands as regular metrics, so it charts and alerts alongside your traces.", + }), + ) {} diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index e0ee0573c..dc8991d4d 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -94,6 +94,7 @@ describe("MapleApiV2 OpenAPI", () => { "DELETE /v2/dashboards/{id}", "DELETE /v2/dashboards/{id}/share", "DELETE /v2/dashboards/{id}/widgets/{widget_id}/share", + "DELETE /v2/integrations/google_analytics", "DELETE /v2/integrations/planetscale", "DELETE /v2/integrations/slack", "DELETE /v2/mobile_devices/{token}", @@ -134,6 +135,7 @@ describe("MapleApiV2 OpenAPI", () => { "GET /v2/ingest_keys", "GET /v2/instrumentation/audit", "GET /v2/instrumentation/recommendations", + "GET /v2/integrations/google_analytics", "GET /v2/integrations/planetscale", "GET /v2/integrations/planetscale/databases", "GET /v2/integrations/planetscale/organizations", @@ -164,6 +166,7 @@ describe("MapleApiV2 OpenAPI", () => { "PATCH /v2/anomalies/settings", "PATCH /v2/attribute_mappings/{id}", "PATCH /v2/dashboards/{id}", + "PATCH /v2/integrations/google_analytics/properties/{property_id}", "PATCH /v2/scrape_targets/{id}", "POST /v2/alerts/destinations", "POST /v2/alerts/destinations/telegram/chats", @@ -186,6 +189,8 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/ingest_keys/public/roll", "POST /v2/instrumentation/recommendations/{id}/dismiss", "POST /v2/instrumentation/recommendations/{id}/reopen", + "POST /v2/integrations/google_analytics/connect", + "POST /v2/integrations/google_analytics/prime", "POST /v2/integrations/planetscale/connect", "POST /v2/integrations/planetscale/events", "POST /v2/integrations/planetscale/metrics_token", From 9974d5f0508dc620bb3be48b6031713542e68c5b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 9 Sep 2026 02:19:44 +0200 Subject: [PATCH 04/11] feat(ga4): dashboard template, integration card, and catalog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend is small because the metrics pipeline does the work: GA4 data lands as ordinary metrics, so the metric explorer needs nothing, and the template gallery's readiness gating keys off the `google_analytics.` prefix rather than any integration id. Every widget in the template uses `sum`. That is the temporality contract, not a preference: these are DELTA sums, and `rate`/`increase` assume cumulative temporality and would double-difference them. Adds a `product` dashboard-template category. The existing four are all operator-facing — what the app or its infrastructure did — and web analytics is about what the app's USERS did; filing it under "application" or "infrastructure" would have been a lie for the sake of not touching an enum. The card puts disconnect in its own body rather than the route header, matching PlanetScale and Slack (Cloudflare's header actions are the outlier), and shows a per-property toggle so an agency grant reaching hundreds of properties is not forced to collect all of them. A revoked grant gets its own state: "Not connected" would be wrong (the connection row is still there) and "Connected" would be a lie (collection has stopped). The connect boundary runs `prime` from the dashboard tab after the popup succeeds. The callback deliberately does not — a first collection takes tens of seconds on a multi-property grant and the popup would sit blank for all of it. The icon is a PLACEHOLDER and is labelled as one in the file. Every other brand mark here carries simple-icons path data and attribution; that package is not vendored, and inventing a path while citing them would plant a false citation. It reads correctly at catalog size and must be swapped before this ships. --- apps/api/src/dashboard-templates/index.ts | 3 + .../product/google-analytics.ts | 245 ++++++++++++++++++ .../auth/GoogleAnalyticsOAuthService.ts | 8 +- .../integrations/GoogleAnalyticsApi.ts | 82 ++++-- .../integrations/GoogleAnalyticsService.ts | 27 +- .../integrations/google-analytics/mapping.ts | 17 +- .../google-analytics/reconcile.ts | 46 ++-- .../templates/template-icons.ts | 3 + .../src/components/icons/google-analytics.tsx | 34 +++ apps/web/src/components/icons/index.ts | 1 + .../google-analytics-integration-card.tsx | 234 +++++++++++++++++ .../integrations/integration-catalog.tsx | 74 ++++++ .../integrations/integration-connect.tsx | 44 ++++ apps/web/src/routes/integrations.tsx | 3 + packages/primitives/src/index.ts | 3 + 15 files changed, 759 insertions(+), 65 deletions(-) create mode 100644 apps/api/src/dashboard-templates/product/google-analytics.ts create mode 100644 apps/web/src/components/icons/google-analytics.tsx create mode 100644 apps/web/src/components/integrations/google-analytics-integration-card.tsx diff --git a/apps/api/src/dashboard-templates/index.ts b/apps/api/src/dashboard-templates/index.ts index b1512a1a4..79e18ce39 100644 --- a/apps/api/src/dashboard-templates/index.ts +++ b/apps/api/src/dashboard-templates/index.ts @@ -23,6 +23,7 @@ import { kubernetesPodTemplate } from "./infrastructure/kubernetes-pod" import { kafkaTemplate } from "./messaging/kafka" import { natsTemplate } from "./messaging/nats" import { rabbitmqTemplate } from "./messaging/rabbitmq" +import { googleAnalyticsTemplate } from "./product/google-analytics" import type { TemplateDefinition, TemplateMetadata, TemplatePreviewWidget } from "./types" export const DASHBOARD_TEMPLATES: ReadonlyArray = [ @@ -53,6 +54,8 @@ export const DASHBOARD_TEMPLATES: ReadonlyArray = [ kafkaTemplate, natsTemplate, rabbitmqTemplate, + // Product + googleAnalyticsTemplate, ] const TEMPLATE_BY_ID = new Map(DASHBOARD_TEMPLATES.map((t) => [t.id, t])) diff --git a/apps/api/src/dashboard-templates/product/google-analytics.ts b/apps/api/src/dashboard-templates/product/google-analytics.ts new file mode 100644 index 000000000..c80d4e8cd --- /dev/null +++ b/apps/api/src/dashboard-templates/product/google-analytics.ts @@ -0,0 +1,245 @@ +import { + CHART_DISPLAY_AREA, + CHART_DISPLAY_BAR, + CHART_DISPLAY_LINE, + buildPortableDashboard, + makeQueryDraft, + metricsTimeseries, + paramKey, + paramValue, + templateId, +} from "@/dashboard-templates/helpers" +import type { TemplateDefinition, WidgetDef } from "@/dashboard-templates/types" + +// GA4 metrics land under ServiceName `google-analytics/{propertyId}` (the +// GoogleAnalyticsService collector). +// +// EVERY widget here uses `sum`, and that is not a stylistic choice. These are +// DELTA-temporality sums — each row is one hour's increment, reconciled against the ledger so a +// revised hour arrives as a difference. `rate` and `increase` assume CUMULATIVE temporality and +// reconstruct increments with `lagInFrame`, so pointing either at these metrics +// double-differences the data (see packages/query-engine/src/query-builder/model.ts). +// +// The `.by_*` suffixes matter too: `channels`, `geo` and `device` each report the same session +// total sliced differently, so they are separate metrics rather than one metric with three +// attributes. Charting `google_analytics.sessions` never double-counts as a result. +function propertyWhere(propertyId?: string): string { + return propertyId ? `service.name = "google-analytics/${propertyId}"` : "" +} + +/** A single-metric stat: reduce one query-builder series to one number. */ +function metricStat(opts: { + id: string + name: string + metricName: string + where: string + unit: string + title: string + layout: WidgetDef["layout"] +}): WidgetDef { + return { + id: opts.id, + visualization: "stat", + dataSource: { + ...metricsTimeseries({ + id: opts.id, + name: opts.name, + metricName: opts.metricName, + metricType: "sum", + aggregation: "sum", + whereClause: opts.where, + }), + transform: { reduceToValue: { field: opts.name, aggregate: "sum" } }, + }, + display: { title: opts.title, unit: opts.unit }, + layout: opts.layout, + } +} + +/** A top-N breakdown: one series per attribute value, ranked by total over the window. */ +function breakdownChart(opts: { + id: string + name: string + metricName: string + attribute: string + where: string + title: string + layout: WidgetDef["layout"] + display: typeof CHART_DISPLAY_BAR | typeof CHART_DISPLAY_AREA +}): WidgetDef { + return { + id: opts.id, + visualization: "chart", + dataSource: metricsTimeseries({ + id: opts.id, + name: opts.name, + metricName: opts.metricName, + metricType: "sum", + aggregation: "sum", + whereClause: opts.where, + groupBy: [`attr.${opts.attribute}`], + }), + display: { title: opts.title, ...opts.display, unit: "number" }, + layout: opts.layout, + } +} + +function widgets(propertyId?: string): WidgetDef[] { + const where = propertyWhere(propertyId) + return [ + metricStat({ + id: "kpi-sessions", + name: "Sessions", + metricName: "google_analytics.sessions", + where, + unit: "number", + title: "Sessions", + layout: { x: 0, y: 0, w: 3, h: 2 }, + }), + metricStat({ + id: "kpi-active-users", + name: "Active users", + metricName: "google_analytics.active_users", + where, + unit: "number", + title: "Active Users", + layout: { x: 3, y: 0, w: 3, h: 2 }, + }), + metricStat({ + id: "kpi-new-users", + name: "New users", + metricName: "google_analytics.new_users", + where, + unit: "number", + title: "New Users", + layout: { x: 6, y: 0, w: 3, h: 2 }, + }), + metricStat({ + id: "kpi-page-views", + name: "Page views", + metricName: "google_analytics.page_views", + where, + unit: "number", + title: "Page Views", + layout: { x: 9, y: 0, w: 3, h: 2 }, + }), + + { + id: "traffic-over-time", + visualization: "chart", + dataSource: metricsTimeseries({ + id: "ga-sessions-over-time", + name: "Sessions", + metricName: "google_analytics.sessions", + metricType: "sum", + aggregation: "sum", + whereClause: where, + }), + display: { title: "Sessions Over Time", ...CHART_DISPLAY_AREA, unit: "number" }, + layout: { x: 0, y: 2, w: 6, h: 6 }, + }, + { + id: "engagement-over-time", + visualization: "chart", + dataSource: metricsTimeseries({ + id: "ga-engaged-sessions", + name: "Engaged sessions", + metricName: "google_analytics.engaged_sessions", + metricType: "sum", + aggregation: "sum", + whereClause: where, + }), + display: { title: "Engaged Sessions", ...CHART_DISPLAY_LINE, unit: "number" }, + layout: { x: 6, y: 2, w: 6, h: 6 }, + }, + + breakdownChart({ + id: "sessions-by-channel", + name: "Sessions", + metricName: "google_analytics.sessions.by_channel", + attribute: "google_analytics.channel_group", + where, + title: "Sessions by Channel", + display: CHART_DISPLAY_AREA, + layout: { x: 0, y: 8, w: 6, h: 6 }, + }), + breakdownChart({ + id: "page-views-by-page", + name: "Page views", + metricName: "google_analytics.page_views.by_page", + attribute: "url.path", + where, + title: "Page Views by Path", + display: CHART_DISPLAY_BAR, + layout: { x: 6, y: 8, w: 6, h: 6 }, + }), + breakdownChart({ + id: "sessions-by-country", + name: "Sessions", + metricName: "google_analytics.sessions.by_country", + attribute: "geo.country_iso_code", + where, + title: "Sessions by Country", + display: CHART_DISPLAY_BAR, + layout: { x: 0, y: 14, w: 4, h: 6 }, + }), + breakdownChart({ + id: "sessions-by-device", + name: "Sessions", + metricName: "google_analytics.sessions.by_device", + attribute: "google_analytics.device_category", + where, + title: "Sessions by Device", + display: CHART_DISPLAY_AREA, + layout: { x: 4, y: 14, w: 4, h: 6 }, + }), + breakdownChart({ + id: "key-events-by-name", + name: "Key events", + metricName: "google_analytics.key_events.by_event", + attribute: "google_analytics.event_name", + where, + title: "Key Events (Conversions)", + display: CHART_DISPLAY_BAR, + layout: { x: 8, y: 14, w: 4, h: 6 }, + }), + ] +} + +export const googleAnalyticsTemplate: TemplateDefinition = { + id: templateId("google-analytics"), + name: "Google Analytics", + description: + "Web analytics from the Google Analytics integration — sessions, users and page views, traffic and engagement over time, plus breakdowns by channel, page, country, device and key event.", + category: "product", + tags: ["google-analytics", "web", "marketing"], + requirement: { + kind: "integration", + label: "Google Analytics integration connected", + missing: "not connected", + collector: "the Google Analytics integration", + setupLabel: "the Google Analytics integration", + hint: "Connect a Google account with access to a GA4 property and every widget fills in on its own.", + }, + requiredMetricPrefixes: ["google_analytics."], + parameters: [ + { + key: paramKey("property_id"), + label: "Property", + description: "Optional — scope every widget to a single GA4 property.", + required: false, + placeholder: "123456789", + }, + ], + build: (params) => { + const propertyId = paramValue(params, "property_id") + return buildPortableDashboard({ + name: propertyId ? `${propertyId} — Google Analytics` : "Google Analytics", + description: + "Google Analytics 4 — sessions, users, page views, and breakdowns by channel, page, country and device.", + tags: ["google-analytics"], + timeRange: "24h", + widgets: widgets(propertyId), + }) + }, +} diff --git a/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts index b4b9eb9ca..8da239cc4 100644 --- a/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts +++ b/apps/api/src/services/auth/GoogleAnalyticsOAuthService.ts @@ -266,8 +266,12 @@ export class GoogleAnalyticsOAuthService extends Context.Service< ) } - const userInfo = yield* fetchUserInfo(config, tokenResponse.access_token) - const identity = Option.getOrElse(userInfo, () => ({}) as { sub?: string; email?: string }) + // Userinfo is a nicety, not a requirement: it names the connected account in the UI. + // An empty identity still yields a valid connection row (see the fallbacks below). + const identity = Option.getOrElse( + yield* fetchUserInfo(config, tokenResponse.access_token), + (): typeof UserInfo.Type => ({}), + ) const accessEnc = yield* oauth.encryptValue(tokenResponse.access_token) const refreshEnc = yield* oauth.encryptValue(tokenResponse.refresh_token) diff --git a/apps/api/src/services/integrations/GoogleAnalyticsApi.ts b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts index f2a762ee0..12c30ce4f 100644 --- a/apps/api/src/services/integrations/GoogleAnalyticsApi.ts +++ b/apps/api/src/services/integrations/GoogleAnalyticsApi.ts @@ -19,7 +19,7 @@ * - Everything else is a plain upstream failure: the watermark simply does not advance. */ import { IntegrationsRevokedError, IntegrationsUpstreamError } from "@maple/domain/http" -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" /** @@ -29,7 +29,13 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ * `Context.Reference` with a default, so a test that overrides it further out still wins. */ const withHttpClient = (effect: Effect.Effect) => - effect.pipe(Effect.provide(FetchHttpClient.layer)) + effect.pipe( + // Not hoistable into the static service graph: these are leaf calls made from a service + // closure, and the layer closes over nothing per-invocation, so providing it here is what + // keeps `HttpClient` out of every caller's requirement channel. + // oxlint-disable-next-line effecttsgo/strict-effect-provide + Effect.provide(FetchHttpClient.layer), + ) /** GA4 quota denial — the caller backs off rather than retrying within the tick. */ export const GA_QUOTA_STATUS = 429 @@ -87,6 +93,17 @@ export interface GoogleAnalyticsProperty { readonly accountName: string | null } +/** The Data API's own `runReport` request body — optional members are OMITTED, never `undefined`. */ +interface RunReportBody { + dimensions: Array<{ name: string }> + metrics: Array<{ name: string }> + dateRanges: Array<{ startDate: string; endDate: string }> + keepEmptyRows: boolean + limit?: string + orderBys?: Array<{ metric: { metricName: string }; desc: boolean }> + dimensionFilter?: unknown +} + /** One `runReport` request, in the Data API's own vocabulary. */ export interface RunReportRequest { readonly dimensions: ReadonlyArray @@ -101,27 +118,39 @@ export interface RunReportRequest { readonly dimensionFilter?: unknown } -const upstream = (message: string, status?: number, cause?: unknown) => - new IntegrationsUpstreamError({ - message, - ...(status === undefined ? {} : { status }), - ...(cause === undefined ? {} : { cause }), - }) +/** Constructor payload for {@link IntegrationsUpstreamError}, whose extras are `optionalKey`. */ +interface UpstreamErrorFields { + message: string + status?: number + cause?: unknown +} + +const upstream = (message: string, status?: number, cause?: unknown) => { + // `status` and `cause` are `optionalKey` on the error, so an explicit `undefined` is not the + // same as omission — assigned only when present. + const fields: UpstreamErrorFields = { message } + if (status !== undefined) fields.status = status + if (cause !== undefined) fields.cause = cause + return new IntegrationsUpstreamError(fields) +} /** * Google's error envelope: `{ error: { code, status, message } }`. `status` is the symbolic * enum ("PERMISSION_DENIED", "RESOURCE_EXHAUSTED"), which is what distinguishes a dead grant * from a quota denial — both arrive as HTTP 403. */ -const errorStatusOf = (text: string): string | null => { - try { - const parsed = JSON.parse(text) as { error?: { status?: unknown } } - const status = parsed.error?.status - return typeof status === "string" ? status : null - } catch { - return null - } -} +const GoogleErrorEnvelope = Schema.Struct({ + error: Schema.optionalKey(Schema.Struct({ status: Schema.optionalKey(Schema.String) })), +}) +const decodeErrorEnvelope = Schema.decodeUnknownOption(Schema.fromJsonString(GoogleErrorEnvelope)) + +const errorStatusOf = (text: string): string | null => + Option.match(decodeErrorEnvelope(text), { + // Google does not promise this envelope on every failure path (a gateway 502 is plain + // HTML), so an undecodable body just means "no symbolic status", not a bug. + onNone: () => null, + onSome: (envelope) => envelope.error?.status ?? null, + }) const classifyFailure = (httpStatus: number, text: string, label: string) => { const symbolic = errorStatusOf(text) @@ -268,20 +297,21 @@ export const runReport = Effect.fn("GoogleAnalyticsApi.runReport")(function* (op }) { const httpClient = yield* HttpClient.HttpClient const { request } = options - const body = { + // Google's own "(other)" bucket silently replaces the tail once a report exceeds its + // cardinality limit. Asking for the totals row would not tell us it happened, so instead the + // caller caps with `limit` and folds its own explicit remainder — see the mapper. + const body: RunReportBody = { dimensions: request.dimensions.map((name) => ({ name })), metrics: request.metrics.map((name) => ({ name })), dateRanges: [{ startDate: request.startDate, endDate: request.endDate }], - ...(request.limit === undefined ? {} : { limit: String(request.limit) }), - ...(request.orderByMetric === undefined - ? {} - : { orderBys: [{ metric: { metricName: request.orderByMetric }, desc: true }] }), - ...(request.dimensionFilter === undefined ? {} : { dimensionFilter: request.dimensionFilter }), - // Google's own "(other)" bucket silently replaces the tail once a report exceeds its - // cardinality limit. Asking for the totals row would not tell us it happened, so instead - // the caller caps with `limit` and folds its own explicit remainder — see the mapper. keepEmptyRows: false, } + // Omitted rather than sent as `undefined`: the Data API rejects a null `limit`. + if (request.limit !== undefined) body.limit = String(request.limit) + if (request.orderByMetric !== undefined) { + body.orderBys = [{ metric: { metricName: request.orderByMetric }, desc: true }] + } + if (request.dimensionFilter !== undefined) body.dimensionFilter = request.dimensionFilter const url = `${options.dataBaseUrl.replace(/\/+$/, "")}/properties/${options.propertyId}:runReport` const response = yield* httpClient diff --git a/apps/api/src/services/integrations/GoogleAnalyticsService.ts b/apps/api/src/services/integrations/GoogleAnalyticsService.ts index 15a9d49ed..06bdffee1 100644 --- a/apps/api/src/services/integrations/GoogleAnalyticsService.ts +++ b/apps/api/src/services/integrations/GoogleAnalyticsService.ts @@ -455,12 +455,15 @@ export class GoogleAnalyticsService extends Context.Service< dataBaseUrl, propertyId: context.row.propertyId, request: { - dimensions: ["dateHour", ...(dataset.breakdown ? [dataset.breakdown.dimension] : [])], + dimensions: dataset.breakdown + ? ["dateHour", dataset.breakdown.dimension] + : ["dateHour"], metrics: dataset.metrics.map((metric) => metric.ga), startDate, endDate, limit: REPORT_ROW_LIMIT, - ...(dataset.breakdown ? { orderByMetric: dataset.breakdown.rankBy } : {}), + // Only a breakdown has a tail to rank; the totals dataset returns one row per hour. + orderByMetric: dataset.breakdown?.rankBy, }, }) @@ -820,17 +823,17 @@ export class GoogleAnalyticsService extends Context.Service< const getIntegrationStatus = Effect.fn("GoogleAnalyticsService.getIntegrationStatus")( function* (orgId: OrgId) { + // A status read must never 500 because the connection row is unreadable: the card + // then shows "not connected", which is the honest answer from the caller's side. const connection = yield* oauth.getStatus(orgId).pipe( - Effect.catch(() => - Effect.succeed({ - connected: false, - connectedAt: null, - externalUserEmail: null, - connectedByUserId: null, - scope: "", - revoked: false, - }), - ), + Effect.orElseSucceed(() => ({ + connected: false, + connectedAt: null, + externalUserEmail: null, + connectedByUserId: null, + scope: "", + revoked: false, + })), ) const rows = yield* loadRows(orgId) diff --git a/apps/api/src/services/integrations/google-analytics/mapping.ts b/apps/api/src/services/integrations/google-analytics/mapping.ts index 462dcf0c0..b53440d6e 100644 --- a/apps/api/src/services/integrations/google-analytics/mapping.ts +++ b/apps/api/src/services/integrations/google-analytics/mapping.ts @@ -100,6 +100,15 @@ export const mapReport = (options: { fold = foldTail(weights, breakdown.maxValues) } + /** A row's metric attributes: the folded breakdown value, or none for the totals dataset. */ + const attributesFor = (row: (typeof rows)[number]): GaSeriesPoint["attributes"] => { + if (breakdown === null) return {} + const raw = dimensionValue(row, breakdownIndex) + const folded = + raw === "" || raw === GA_OTHER_ROW ? OTHER_BUCKET : fold(truncate(raw, breakdown.maxValueLength)) + return { [breakdown.attributeKey]: folded } + } + // Pass 2: accumulate, because folding merges many raw values into one `other` series and the // same (bucket, series) must arrive at the ledger exactly once. const accumulated = new Map() @@ -107,13 +116,7 @@ export const mapReport = (options: { const bucketMs = dateHourToUtcMs(dimensionValue(row, hourIndex), timeZone) if (bucketMs === null) continue - let attributes: Record = {} - if (breakdown !== null) { - const raw = dimensionValue(row, breakdownIndex) - const folded = - raw === "" || raw === GA_OTHER_ROW ? OTHER_BUCKET : fold(truncate(raw, breakdown.maxValueLength)) - attributes = { [breakdown.attributeKey]: folded } - } + const attributes = attributesFor(row) for (const { metric, index } of metricIndexes) { if (index < 0) continue diff --git a/apps/api/src/services/integrations/google-analytics/reconcile.ts b/apps/api/src/services/integrations/google-analytics/reconcile.ts index 2f00e5121..34b7a1905 100644 --- a/apps/api/src/services/integrations/google-analytics/reconcile.ts +++ b/apps/api/src/services/integrations/google-analytics/reconcile.ts @@ -18,7 +18,8 @@ * temporality and would double-difference the data (see * `packages/query-engine/src/query-builder/model.ts`). */ -import { fmtMetricTs, type MetricSumRow } from "@/services/warehouse/metric-rows" +import { Option, Schema } from "effect" +import { fmtMetricTs, type MetricAttrs, type MetricSumRow } from "@/services/warehouse/metric-rows" import type { GaDatasetDef } from "./datasets" import { SCOPE_NAME, serviceNameFor } from "./datasets" import { type GaSeriesPoint, seriesKey } from "./mapping" @@ -41,19 +42,23 @@ export interface ReconcileResult { * which re-emits the bucket's full current value — a visible double-count in one hour, versus * silently freezing that hour forever if we treated the failure as "already up to date". */ +const LedgerBlob = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) +const decodeLedgerBlob = Schema.decodeUnknownOption(LedgerBlob) +const decodeEmittedValue = Schema.decodeUnknownOption(Schema.Finite) + export const parseLedger = (json: string | null | undefined): Readonly> => { if (json == null || json === "") return {} - try { - const parsed: unknown = JSON.parse(json) - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {} - const out: Record = {} - for (const [key, value] of Object.entries(parsed as Record)) { - if (typeof value === "number" && Number.isFinite(value)) out[key] = value - } - return out - } catch { - return {} + const blob = Option.getOrNull(decodeLedgerBlob(json)) + if (blob === null) return {} + // Filtered per key rather than validated as a whole: the blob is a map of INDEPENDENT series, + // so one unreadable entry should cost that series a re-emission, not force every other series + // in the hour to be re-emitted alongside it. + const emitted: Record = {} + for (const [key, value] of Object.entries(blob)) { + const parsed = decodeEmittedValue(value) + if (Option.isSome(parsed)) emitted[key] = parsed.value } + return emitted } export const serializeLedger = (emitted: Readonly>): string => JSON.stringify(emitted) @@ -63,13 +68,18 @@ const resourceAttributes = (options: { readonly propertyId: string readonly propertyName: string | null readonly accountName: string | null -}): Record => ({ - maple_org_id: options.orgId, - "service.name": serviceNameFor(options.propertyId), - "google_analytics.property.id": options.propertyId, - ...(options.propertyName == null ? {} : { "google_analytics.property.name": options.propertyName }), - ...(options.accountName == null ? {} : { "google_analytics.account.name": options.accountName }), -}) +}): MetricAttrs => { + const attributes: MetricAttrs = { + maple_org_id: options.orgId, + "service.name": serviceNameFor(options.propertyId), + "google_analytics.property.id": options.propertyId, + } + // Display names are absent until discovery has named the property; an empty-string attribute + // would read as "named, blank" everywhere downstream. + if (options.propertyName != null) attributes["google_analytics.property.name"] = options.propertyName + if (options.accountName != null) attributes["google_analytics.account.name"] = options.accountName + return attributes +} /** * Reconcile one dataset's freshly-polled points against the ledger. diff --git a/apps/web/src/components/dashboard-builder/templates/template-icons.ts b/apps/web/src/components/dashboard-builder/templates/template-icons.ts index 6cd11a7a0..97e138c36 100644 --- a/apps/web/src/components/dashboard-builder/templates/template-icons.ts +++ b/apps/web/src/components/dashboard-builder/templates/template-icons.ts @@ -5,6 +5,7 @@ import { CloudflareIcon, DatabaseIcon, GlobeIcon, + GoogleAnalyticsIcon, GridSquareCirclePlusIcon, type IconComponent, KafkaIcon, @@ -42,6 +43,7 @@ const TEMPLATE_ICONS: Record = { "mongodb-overview": MongodbIcon, cloudflare: CloudflareIcon, planetscale: PlanetScaleIcon, + "google-analytics": GoogleAnalyticsIcon, "host-metrics": ServerIcon, "kubernetes-cluster": KubernetesIcon, "kubernetes-pod": KubernetesIcon, @@ -56,6 +58,7 @@ const CATEGORY_ICONS: Record = { database: DatabaseIcon, infrastructure: ServerIcon, messaging: PaperPlaneIcon, + product: ChartLineIcon, } satisfies Record export function templateIcon(templateId: string, category: string): IconComponent { diff --git a/apps/web/src/components/icons/google-analytics.tsx b/apps/web/src/components/icons/google-analytics.tsx new file mode 100644 index 000000000..e6edb44b4 --- /dev/null +++ b/apps/web/src/components/icons/google-analytics.tsx @@ -0,0 +1,34 @@ +import type { IconProps } from "./icon" + +/** + * PLACEHOLDER, not Google's brand asset. + * + * Every other third-party mark in this directory carries a `Source: simple-icons (MIT)` line and + * that project's exact path data. simple-icons is not vendored in this repo, so rather than + * inventing a path and attributing it to them — a false citation someone would later take at face + * value — this is a plain geometric ascending-bars glyph in GA's brand orange. + * + * It reads correctly at catalog size (GA4's own mark is a bar arrangement), but before this ships + * to customers, replace the paths below with `simpleicons.org/icons/googleanalytics` and restore + * the attribution comment. Keep `currentColor` and the `size`/`className` contract. + */ +function GoogleAnalyticsIcon({ size = 24, className, ...props }: IconProps) { + return ( + + ) +} + +export { GoogleAnalyticsIcon } diff --git a/apps/web/src/components/icons/index.ts b/apps/web/src/components/icons/index.ts index ad71d1018..b88b78ebb 100644 --- a/apps/web/src/components/icons/index.ts +++ b/apps/web/src/components/icons/index.ts @@ -113,6 +113,7 @@ export { FolderIcon } from "./folder" export { GearIcon } from "./gear" export { GeminiIcon } from "./gemini" export { GithubIcon } from "./github" +export { GoogleAnalyticsIcon } from "./google-analytics" export { GoogleIcon } from "./google" export { GrokIcon } from "./grok" export { HaystackIcon } from "./haystack" diff --git a/apps/web/src/components/integrations/google-analytics-integration-card.tsx b/apps/web/src/components/integrations/google-analytics-integration-card.tsx new file mode 100644 index 000000000..bb9048f96 --- /dev/null +++ b/apps/web/src/components/integrations/google-analytics-integration-card.tsx @@ -0,0 +1,234 @@ +import { Exit } from "effect" +import { Badge } from "@maple/ui/components/ui/badge" +import { Button } from "@maple/ui/components/ui/button" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { Switch } from "@maple/ui/components/ui/switch" +import { toastManager } from "@maple/ui/components/ui/toast" + +import { GoogleAnalyticsIcon, LoaderIcon } from "@/components/icons" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { Result, useAtomRefresh, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" +import { showErrorToast } from "@/lib/error-toast" +import { IntegrationIconPlate, catalogEntry } from "./integration-catalog" +import { useIntegrationConnect } from "./integration-connect" +import { + IntegrationEmpty, + IntegrationEmptyCard, + IntegrationEmptyFeature, + IntegrationEmptyFeatures, + IntegrationEmptyHint, + IntegrationEmptyMedia, +} from "./integration-empty-state" + +const GA_ENTRY = catalogEntry("google-analytics") + +/** + * Google Analytics 4 connection card: authorize a Google account in a popup, and every GA4 + * property that account can see is discovered and collected automatically. Per-property toggles + * are the one knob — an agency account reaching hundreds of properties should not have to collect + * all of them. + * + * Disconnect lives in the card body rather than the route header, matching PlanetScale and Slack; + * Cloudflare's header actions are the outlier. + */ +export function GoogleAnalyticsIntegrationCard() { + const statusQuery = retainedQueryV2("googleAnalyticsIntegration", "status", { + reactivityKeys: ["googleAnalyticsIntegration"], + }) + const statusResult = useAtomValue(statusQuery) + const refreshStatus = useAtomRefresh(statusQuery) + + const connectFlow = useIntegrationConnect() + if (connectFlow === null) { + throw new Error("GoogleAnalyticsIntegrationCard must render inside IntegrationConnectProvider") + } + + const disconnect = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "disconnect"), + { mode: "promiseExit" }, + ) + const updateProperty = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "updateProperty"), + { mode: "promiseExit" }, + ) + + const handleDisconnect = async () => { + const result = await disconnect({ reactivityKeys: ["googleAnalyticsIntegration"] }) + if (Exit.isSuccess(result)) { + toastManager.add({ title: "Google Analytics disconnected", type: "success" }) + refreshStatus() + } else { + showErrorToast(result, { fallbackTitle: "Failed to disconnect Google Analytics" }) + } + } + + const handleToggle = async (propertyId: string, enabled: boolean) => { + const result = await updateProperty({ + params: { property_id: propertyId }, + payload: { enabled }, + reactivityKeys: ["googleAnalyticsIntegration"], + }) + if (Exit.isSuccess(result)) { + refreshStatus() + } else { + showErrorToast(result, { fallbackTitle: "Failed to update the property" }) + } + } + + if (Result.isInitial(statusResult)) { + return + } + + // A failed status fetch is not "not connected" — don't offer the connect CTA over an + // account that may already be authorized. + if (Result.isFailure(statusResult)) { + return ( +
+ +
+

Google Analytics

+

+ Couldn't load the Google Analytics connection status — refresh the page to try + again. +

+
+
+ ) + } + + const status = statusResult.value + + if (!status.connected) { + return ( + + + + + + + + + + Every GA4 property the Google account can see appears here after connecting. + + + + + ) + } + + return ( +
+
+
+ +
+
+

Google Analytics

+ {status.revoked ? ( + Reconnect needed + ) : ( + Connected + )} +
+

+ {status.connected_email ?? "Connected Google account"} +

+
+
+
+ {status.revoked && ( + + )} + +
+
+ + {status.revoked && ( +

+ Google rejected the stored authorization, so collection has stopped. Everything already + collected is still here — reconnect to resume. +

+ )} + +
+ {status.properties.length === 0 ? ( +

+ No GA4 properties discovered yet. Discovery runs hourly after connecting. +

+ ) : ( +
    + {status.properties.map((property) => ( +
  • +
    +
    + + {property.property_name ?? property.property_id} + + + {property.property_id} + +
    +

    + {propertyDetail(property)} +

    +
    + void handleToggle(property.property_id, checked)} + aria-label={`Collect ${property.property_name ?? property.property_id}`} + /> +
  • + ))} +
+ )} +
+
+ ) +} + +type PropertyStatus = { + readonly enabled: boolean + readonly account_name: string | null + readonly time_zone: string | null + readonly last_error: string | null + readonly last_synced_at: string | null +} + +/** The one line under a property's name: whatever most needs saying about it. */ +function propertyDetail(property: PropertyStatus): string { + if (!property.enabled) return "Not collected" + if (property.last_error !== null) return property.last_error + // No timezone means the property has never been collected — GA4 reports hourly data in the + // property's own zone, so nothing can be placed on the timeline until it resolves. + if (property.time_zone === null) return "Waiting for the first collection" + const account = property.account_name ?? "Google Analytics" + return property.last_synced_at === null + ? account + : `${account} · synced ${formatRelativeTime(property.last_synced_at)}` +} diff --git a/apps/web/src/components/integrations/integration-catalog.tsx b/apps/web/src/components/integrations/integration-catalog.tsx index d48d1adbb..5e05e9aba 100644 --- a/apps/web/src/components/integrations/integration-catalog.tsx +++ b/apps/web/src/components/integrations/integration-catalog.tsx @@ -8,6 +8,7 @@ import { ChevronRightIcon, CloudflareIcon, GithubIcon, + GoogleAnalyticsIcon, HazelIcon, PlanetScaleIcon, PrometheusIcon, @@ -29,6 +30,7 @@ export type IntegrationId = | "hazel" | "github" | "slack" + | "google-analytics" /** * Third-party brand accents for the icon-plate wash — no app token applies. @@ -37,6 +39,8 @@ export type IntegrationId = export const GITHUB_ACCENT = "#181717" export const HAZEL_ACCENT = "#F46F0F" export const CLOUDFLARE_ACCENT = "#F38020" +/** Google Analytics 4 brand orange. */ +export const GOOGLE_ANALYTICS_ACCENT = "#E37400" /** * Slack's deep aubergine — the brand's identity color, and the light-theme value. @@ -148,6 +152,15 @@ const CATALOG: ReadonlyArray = [ accent: SLACK_ACCENT, docsUrl: "https://maple.dev/docs/integrations/slack", }, + { + id: "google-analytics", + name: "Google Analytics", + description: + "Connect a Google account to chart GA4 sessions, users and page views next to your traces and errors.", + icon: GoogleAnalyticsIcon, + accent: GOOGLE_ANALYTICS_ACCENT, + docsUrl: "https://maple.dev/docs/integrations/google-analytics", + }, ] /** @@ -210,6 +223,11 @@ export function useIntegrationStatuses(): Partial null) .orElse(() => STATUS_UNAVAILABLE) + const googleAnalytics: CardStatus | null = Result.builder(googleAnalyticsResult) + .onSuccess((status): CardStatus => { + if (!status.connected) return NOT_CONNECTED + // A revoked grant still has a connection row, so "Not connected" would be wrong and + // "Connected" would be a lie — collection has stopped until someone reconnects. + if (status.revoked) return { label: "Reconnect needed", variant: "warning" } + const collecting = status.properties.filter((property) => property.enabled).length + return { + label: collecting > 0 ? `${collecting} propert${collecting === 1 ? "y" : "ies"}` : "Connected", + variant: "success", + } + }) + .onInitial(() => null) + .orElse(() => STATUS_UNAVAILABLE) + return { cloudflare, prometheus: scrapeStatus("prometheus"), @@ -294,6 +327,7 @@ export function useIntegrationStatuses(): Partial { @@ -614,6 +653,40 @@ export function useIntegrationOverviews(): Record null) .orElse(() => UNAVAILABLE) + const googleAnalytics: IntegrationOverview = Result.builder(googleAnalyticsResult) + .onSuccess((status): IntegrationOverview => { + if (!status.connected) return CONNECT + const collecting = status.properties.filter((property) => property.enabled) + const erroring = collecting.filter((property) => property.last_error !== null) + // A property with no timezone yet has never been collected — Google reports hourly + // data in the property's own zone, so nothing can be placed until it resolves. + const unresolved = collecting.filter((property) => property.time_zone === null) + const issue = status.revoked + ? "authorization revoked" + : erroring.length > 0 + ? `${plural(erroring.length, "property")} erroring` + : unresolved.length > 0 + ? `${plural(unresolved.length, "property")} not started` + : null + return { + kind: "connected", + health: issue ? "attention" : "healthy", + stateLabel: status.revoked ? "Reconnect needed" : issue ? "Needs attention" : "Healthy", + context: status.connected_email, + stat: collecting.length > 0 ? `${plural(collecting.length, "property")} collected` : null, + lastSyncLabel: syncedLabel( + maxMs( + collecting.map((property) => + property.last_synced_at ? Date.parse(property.last_synced_at) : null, + ), + ), + ), + issue, + } + }) + .onInitial(() => null) + .orElse(() => UNAVAILABLE) + return { cloudflare, prometheus: scrapeOverview("prometheus"), @@ -623,6 +696,7 @@ export function useIntegrationOverviews(): Record{children} case "planetscale": return {children} + case "google-analytics": + return {children} default: return children } @@ -389,3 +391,45 @@ function PlanetscaleConnectBoundary({ children }: { children: React.ReactNode }) return {children} } + +function GoogleAnalyticsConnectBoundary({ children }: { children: React.ReactNode }) { + const refreshStatus = useAtomRefresh( + retainedQueryV2("googleAnalyticsIntegration", "status", { + reactivityKeys: ["googleAnalyticsIntegration"], + }), + ) + const startConnect = useAtomSet( + MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "connect"), + { mode: "promiseExit" }, + ) + const prime = useAtomSet(MapleApiV2AtomClient.mutation("googleAnalyticsIntegration", "prime"), { + mode: "promiseExit", + }) + + useIntegrationMessage("maple:integration:google-analytics", (data) => { + if (data.status === "success") { + // The callback deliberately does NOT run the first collection — it takes tens of + // seconds on a grant with several properties and the popup would sit blank for all + // of it. This tab is still open, so it runs it here and refreshes when it lands. + void prime({ reactivityKeys: ["googleAnalyticsIntegration"] }).finally(refreshStatus) + refreshStatus() + } else if (data.status === "error") { + toastManager.add({ title: data.message ?? "Google Analytics connection failed", type: "error" }) + } + }) + + const value = useOAuthPopupFlow({ + windowName: "maple-google-analytics-connect", + label: "Google Analytics", + windowFeatures: "popup,width=520,height=680", + start: () => + startConnect({ + payload: { return_to: currentReturnPath() }, + reactivityKeys: ["googleAnalyticsIntegration"], + }).then(Exit.map(({ redirect_url }) => ({ redirectUrl: redirect_url }))), + startErrorTitle: "Failed to start Google Analytics connect flow", + onClosed: refreshStatus, + }) + + return {children} +} diff --git a/apps/web/src/routes/integrations.tsx b/apps/web/src/routes/integrations.tsx index c40f8c674..37f75cad3 100644 --- a/apps/web/src/routes/integrations.tsx +++ b/apps/web/src/routes/integrations.tsx @@ -11,6 +11,7 @@ import { import { GithubIntegrationCard } from "@/components/integrations/github-integration-card" import { HazelIntegrationCard } from "@/components/integrations/hazel-integration-card" import { PlanetScaleIntegrationCard } from "@/components/integrations/planetscale-integration-card" +import { GoogleAnalyticsIntegrationCard } from "@/components/integrations/google-analytics-integration-card" import { SlackIntegrationCard } from "@/components/integrations/slack-integration-card" import { IntegrationCatalog, @@ -226,6 +227,8 @@ function IntegrationsPage() { ) : integration === "slack" ? ( + ) : integration === "google-analytics" ? ( + ) : ( // prometheus + warpstream share the generic scrape-target flow diff --git a/packages/primitives/src/index.ts b/packages/primitives/src/index.ts index 3c909ff7e..a2dedc8c7 100644 --- a/packages/primitives/src/index.ts +++ b/packages/primitives/src/index.ts @@ -72,6 +72,9 @@ export const DashboardTemplateCategory = Schema.Literals([ "database", "infrastructure", "messaging", + // What the app's USERS did, rather than what the app or its infrastructure did — + // web and product analytics. The other four are all operator-facing. + "product", ]).annotate({ identifier: "@maple/DashboardTemplateCategory", title: "Dashboard Template Category", From 29e7b041921c0e52bcd78125dad0cadbab66c209 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 9 Sep 2026 04:33:54 +0200 Subject: [PATCH 05/11] fix(ga4): regenerate the iOS OpenAPI spec and raise the web bundle budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI checks, two of them mine. The iOS spec is generated from the v2 surface and checked in, so registering a new group leaves it stale. Regenerating adds exactly the group's tag entry and nothing else. The startup bundle lands at 686.2 KB against a 685 KB budget. Isolated by registering and unregistering `V2GoogleAnalyticsIntegrationsApiGroup` against the same build: 686.2 with it, 684.6 without. So the whole 1.6 KB is the v2 domain contract every page's API client carries, and the card, catalog entry, icon and template-icon entry cost nothing measurable between them. Same category as the Releases and AI-detect contract entries already recorded in that file, just larger — five endpoints and six schemas rather than one. The weight is the OpenAPI descriptions, which are the public API documentation; trimming them to buy back a kilobyte of startup would be the wrong trade, and the group cannot be split out of the client because every page's client is built from the whole `MapleApiV2` surface. The `sidebar-icons.tsx` lint failure is NOT from this branch — it arrived with fcf492cc9e and main is already red on it. Fixed here because it blocks this PR: the explicit `Record` annotation is dropped for `satisfies`, and the lookup narrows through a type predicate rather than an inline cast, so an unknown frontmatter name still returns undefined. --- apps/clickhouse-builder-docs/src/sidebar-icons.tsx | 11 ++++++++--- .../Packages/MapleAPI/Sources/MapleAPI/openapi.json | 4 ++++ apps/web/perf/check-bundle-budget.ts | 13 ++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx index 35013e6d2..d46c5cced 100644 --- a/apps/clickhouse-builder-docs/src/sidebar-icons.tsx +++ b/apps/clickhouse-builder-docs/src/sidebar-icons.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from "react" // Nucleo geometry from Maple’s existing icon set (apps/web/src/components/icons). -const icons: Record = { +const icons = { "branch-fork": ( <> {" "} @@ -297,10 +297,15 @@ const icons: Record = { ))}{" "} ), -} +} satisfies Record + +type SidebarIconName = keyof typeof icons + +/** Sidebar names come from doc frontmatter, so an unknown one is expected, not a bug. */ +const isSidebarIconName = (name: string): name is SidebarIconName => name in icons export function sidebarIcon(name: string | undefined) { - const icon = name ? icons[name] : undefined + const icon = name !== undefined && isSidebarIconName(name) ? icons[name] : undefined if (!icon) return undefined return (
+ // Wraps rather than overflowing: the action group is `shrink-0` (a Connect button that + // shrinks becomes an ellipsis), so on a narrow viewport it has to move to its own row or + // it runs off the edge. `entry.name` is the other half — the longest one in the catalog is + // "Google Analytics", which is what made the collision obvious. +