From cd01865018b6ebf08bee82e8a38af5cd7410319f Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:41:34 +0200 Subject: [PATCH 01/17] feat(integrations): Google Cloud metrics and resource poller --- .env.example | 6 + apps/alerting/src/scheduled.test.ts | 15 +- apps/alerting/src/scheduled.ts | 22 + apps/alerting/src/worker.ts | 2 + packages/backend/src/platform/Env.ts | 6 + .../integrations/GcpMetricsService.test.ts | 974 ++++++++++++++++++ .../integrations/GcpMetricsService.ts | 584 +++++++++++ .../src/services/integrations/gcp/api.ts | 379 +++++++ .../services/integrations/gcp/mapping.test.ts | 260 +++++ .../src/services/integrations/gcp/mapping.ts | 220 ++++ .../src/services/org/OrganizationService.ts | 3 + packages/db/src/schema/gcp-connectors.ts | 10 + packages/db/src/schema/gcp-resources.ts | 33 + packages/db/src/schema/index.ts | 1 + packages/domain/package.json | 1 + packages/domain/src/gcp-metrics.test.ts | 36 + packages/domain/src/gcp-metrics.ts | 409 ++++++++ 17 files changed, 2960 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/services/integrations/GcpMetricsService.test.ts create mode 100644 packages/backend/src/services/integrations/GcpMetricsService.ts create mode 100644 packages/backend/src/services/integrations/gcp/api.ts create mode 100644 packages/backend/src/services/integrations/gcp/mapping.test.ts create mode 100644 packages/backend/src/services/integrations/gcp/mapping.ts create mode 100644 packages/db/src/schema/gcp-resources.ts create mode 100644 packages/domain/src/gcp-metrics.test.ts create mode 100644 packages/domain/src/gcp-metrics.ts diff --git a/.env.example b/.env.example index fee5836d97..b21a826c32 100644 --- a/.env.example +++ b/.env.example @@ -192,6 +192,12 @@ INGEST_REQUIRE_TLS=false # Logs need no configuration. For metrics, set the email of a Google service account Maple owns; # the setup script lets it impersonate a read-only metrics account in the customer's project. # MAPLE_GCP_SERVICE_ACCOUNT_EMAIL= +# A key of that same account, for the metrics and resource poller (alerting Worker). The JSON +# key file, base64-encoded on one line so it survives a Worker secret and an env file unchanged: +# base64 < key.json | tr -d '\n' +# Unset: nothing is polled. The account needs no role of its own, but its project must have +# the IAM Service Account Credentials API enabled. +# MAPLE_GCP_SERVICE_ACCOUNT_KEY= # OpenTelemetry (API self-observability) — read by @maple-dev/effect-sdk # MAPLE_ENVIRONMENT=local # "local" = no export; any other value enables OTLP diff --git a/apps/alerting/src/scheduled.test.ts b/apps/alerting/src/scheduled.test.ts index 19a0ead28b..4704a44605 100644 --- a/apps/alerting/src/scheduled.test.ts +++ b/apps/alerting/src/scheduled.test.ts @@ -4,7 +4,17 @@ import { env as workerEnv } from "../test/stubs/cloudflare-workers" import { buildLayer, catchTickFailure, selectScheduledProgram, type ScheduledTickPrograms } from "./scheduled" const cronCases = [ - ["*/5 * * * *", ["anomaly", "cloudflareAnalytics", "planetScale", "railwayMetrics", "prReviewPostMerge"]], + [ + "*/5 * * * *", + [ + "anomaly", + "cloudflareAnalytics", + "gcpMetrics", + "planetScale", + "railwayMetrics", + "prReviewPostMerge", + ], + ], ["*/15 * * * *", ["digest"]], ["0 * * * *", ["serviceMapRollup"]], ["* * * * *", ["alert", "error", "escalation", "fixVerification"]], @@ -27,6 +37,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + gcpMetrics: tick("gcpMetrics"), planetScale: tick("planetScale"), prReviewPostMerge: tick("prReviewPostMerge"), railwayMetrics: tick("railwayMetrics"), @@ -58,6 +69,7 @@ describe("alerting Effect root", () => { error: errorGate.await.pipe(Effect.andThen(record("error"))), escalation: record("escalation"), fixVerification: record("fixVerification"), + gcpMetrics: record("gcpMetrics"), planetScale: record("planetScale"), prReviewPostMerge: record("prReviewPostMerge"), railwayMetrics: record("railwayMetrics"), @@ -85,6 +97,7 @@ describe("alerting Effect root", () => { error: tick("error"), escalation: tick("escalation"), fixVerification: tick("fixVerification"), + gcpMetrics: tick("gcpMetrics"), planetScale: tick("planetScale"), prReviewPostMerge: tick("prReviewPostMerge"), railwayMetrics: tick("railwayMetrics"), diff --git a/apps/alerting/src/scheduled.ts b/apps/alerting/src/scheduled.ts index f6cd6e7302..4584c47a8b 100644 --- a/apps/alerting/src/scheduled.ts +++ b/apps/alerting/src/scheduled.ts @@ -15,6 +15,7 @@ import { Env } from "@maple/backend/platform/Env" import { ErrorsService } from "@maple/backend/services/errors/ErrorsService" import { EscalationService } from "@maple/backend/services/alerts/EscalationService" import { FixVerificationTickService } from "@maple/backend/services/errors/FixVerificationTickService" +import { GcpMetricsService } from "@maple/backend/services/integrations/GcpMetricsService" import { IncidentClassifier } from "@maple/backend/services/errors/IncidentClassifier" import { layerPg } from "@maple/backend/platform/DatabasePgLive" import { PullRequestLookupLive } from "@maple/backend/services/errors/pull-request-lookup-live" @@ -48,6 +49,7 @@ export const buildLayer = ( AlertsService.layer, AnomalyDetectionService.layer, CloudflareAnalyticsService.layer, + GcpMetricsService.layer, PlanetScaleService.layer, RailwayMetricsService.layer, DigestService.layer, @@ -249,6 +251,22 @@ const cloudflareAnalyticsTick = makeTick( }), ) +const gcpMetricsTick = makeTick( + GcpMetricsService.use((gcp) => gcp.pollAll()), + "gcp_metrics", + (result) => + result.polled + result.deferred > 0 + ? { + polled: result.polled, + deferred: result.deferred, + rowsIngested: result.rowsIngested, + failures: result.failures, + incompleteMetrics: result.incompleteMetrics, + calls: result.calls, + } + : undefined, +) + const planetScaleTick = makeTick( PlanetScaleService.use((planetscale) => planetscale.pollAllOrgs()), "planetscale", @@ -301,6 +319,7 @@ export interface ScheduledTickPrograms { readonly error: Effect.Effect readonly escalation: Effect.Effect readonly fixVerification: Effect.Effect + readonly gcpMetrics: Effect.Effect readonly planetScale: Effect.Effect readonly prReviewPostMerge: Effect.Effect readonly railwayMetrics: Effect.Effect @@ -322,6 +341,7 @@ export const selectScheduledProgram = ( [ ticks.anomaly, ticks.cloudflareAnalytics, + ticks.gcpMetrics, ticks.planetScale, ticks.railwayMetrics, ticks.prReviewPostMerge, @@ -360,6 +380,7 @@ type ScheduledServices = | ErrorsService | EscalationService | FixVerificationTickService + | GcpMetricsService | PlanetScaleService | PrReviewPostMergeService | RailwayMetricsService @@ -373,6 +394,7 @@ export const scheduledTicks: ScheduledTickPrograms = { error: errorTick, escalation: escalationTick, fixVerification: fixVerificationTick, + gcpMetrics: gcpMetricsTick, planetScale: planetScaleTick, prReviewPostMerge: prReviewPostMergeTick, railwayMetrics: railwayMetricsTick, diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index 9dcb68645a..b4111f365f 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -63,6 +63,8 @@ const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDom apnsEnv, cloudflareOAuthEnv, planetScaleOAuthEnv, + // Sign-in for the Google Cloud metrics poller; unset leaves its tick a no-op. + optionalSecret("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), // Outbound config for `chat` destinations' connectors. ...chatConnectorOutboundConfigKeys.map((key) => key.secret ? optionalSecret(key.name) : optionalPlain(key.name), diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index e68e2847b5..0ff497e5f1 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -154,6 +154,11 @@ export interface EnvConfig { * metrics reader their setup script creates. Unset: the GCP integration is logs-only. */ readonly MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: Option.Option + /** + * A key of that service account: its JSON key file, base64-encoded. The metrics and resource + * poller signs in with it. Unset: nothing is polled. + */ + readonly MAPLE_GCP_SERVICE_ACCOUNT_KEY: Option.Option> readonly CLOUDFLARE_OAUTH_CLIENT_ID: Option.Option readonly CLOUDFLARE_OAUTH_CLIENT_SECRET: Option.Option> readonly CLOUDFLARE_OAUTH_SCOPES: string @@ -262,6 +267,7 @@ const envConfig = Config.all({ GITHUB_APP_WEBHOOK_SECRET: optionalRedacted("GITHUB_APP_WEBHOOK_SECRET"), GITHUB_API_BASE_URL: stringWithDefault("GITHUB_API_BASE_URL", "https://api.github.com"), MAPLE_GCP_SERVICE_ACCOUNT_EMAIL: optionalString("MAPLE_GCP_SERVICE_ACCOUNT_EMAIL"), + MAPLE_GCP_SERVICE_ACCOUNT_KEY: optionalRedacted("MAPLE_GCP_SERVICE_ACCOUNT_KEY"), CLOUDFLARE_OAUTH_CLIENT_ID: optionalString("CLOUDFLARE_OAUTH_CLIENT_ID"), CLOUDFLARE_OAUTH_CLIENT_SECRET: optionalRedacted("CLOUDFLARE_OAUTH_CLIENT_SECRET"), // Cloudflare OAuth scope ids are DOT-delimited (mirroring API-token permission names; diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts new file mode 100644 index 0000000000..0cdafb5c0e --- /dev/null +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -0,0 +1,974 @@ +import { afterEach, assert, beforeAll, describe, it } from "@effect/vitest" +import { ConfigProvider, Effect, Exit, Layer, Logger, References, Tracer } from "effect" +import { TestClock } from "effect/testing" +import { FetchHttpClient } from "effect/http" +import { GCP_ASSET_TYPES, GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" +import { Env } from "@maple/backend/platform/Env" +import { + cleanupTestDbs, + createTestDb, + executeSql, + queryFirstRow, + type TestDb, +} from "@maple/backend/platform/test-pglite" +import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" +import { GcpMetricsService, MAX_PAGES_PER_METRIC, MAX_RESOURCE_PAGES, nextWindow } from "./GcpMetricsService" + +const minute = 60_000 +const now = Date.UTC(2026, 9, 8, 12, 30, 30) +/** Five minutes behind `now`, on a minute boundary. */ +const horizon = Date.UTC(2026, 9, 8, 12, 25, 0) +const METRIC_COUNT = GCP_METRIC_GROUPS.reduce((sum, group) => sum + group.metrics.length, 0) + +describe("nextWindow", () => { + it("starts an hour back on a fresh connector and ends five minutes behind now", () => { + assert.deepStrictEqual(nextWindow(null, now), { startMs: horizon - 60 * minute, endMs: horizon }) + }) + + it("continues from the watermark, and is null once caught up", () => { + const watermark = new Date(horizon - 5 * minute) + assert.deepStrictEqual(nextWindow(watermark, now), { startMs: watermark.getTime(), endMs: horizon }) + assert.isNull(nextWindow(new Date(horizon), now)) + }) + + it("catches up a long gap one hour per poll, skipping nothing", () => { + const watermark = new Date(horizon - 48 * 60 * minute) + assert.deepStrictEqual(nextWindow(watermark, now), { + startMs: watermark.getTime(), + endMs: watermark.getTime() + 60 * minute, + }) + }) +}) + +const MAPLE_ACCOUNT = "collector@maple-prod.iam.gserviceaccount.com" +const MAPLE_TOKEN = "ya29.maple-own-token" +const READER_TOKEN = "ya29.customer-reader-token" +const CONNECTOR_A = "018f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8" +const CONNECTOR_B = "028f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8" +const READER_A = "maple-018f2b3c4d5e4f708192a3b4@acme-prod.iam.gserviceaccount.com" +const REQUEST_COUNT = "run.googleapis.com/request_count" + +let encodedKey = "" +let privateKeyBody = "" +let publicKey: CryptoKey + +beforeAll(async () => { + const pair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-256", + }, + true, + ["sign", "verify"], + ) + publicKey = pair.publicKey + privateKeyBody = Buffer.from(await crypto.subtle.exportKey("pkcs8", pair.privateKey)).toString("base64") + const lines = privateKeyBody.match(/.{1,64}/g)!.join("\n") + encodedKey = Buffer.from( + JSON.stringify({ + type: "service_account", + private_key_id: "key-1", + private_key: `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----\n`, + client_email: MAPLE_ACCOUNT, + }), + ).toString("base64") +}) + +const trackedDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(trackedDbs)) + +interface ScopedCall { + /** `projects/`, `folders/` or `organizations/`. */ + readonly scope: string + readonly params: URLSearchParams + readonly authorization: string | null +} + +interface MonitoringCall extends ScopedCall { + readonly metricType: string +} + +interface Calls { + readonly oauth: Array + readonly impersonate: Array<{ + readonly account: string + readonly authorization: string | null + readonly body: unknown + }> + readonly monitoring: Array + readonly assets: Array + readonly ingest: Array<{ readonly authorization: string | null; readonly body: string }> +} + +interface StubOptions { + readonly oauth?: () => Response + /** Answer for one impersonation; undefined grants a token. */ + readonly impersonate?: (account: string) => Response | undefined + /** Answer for one `timeSeries.list` call; undefined answers "no series". */ + readonly monitoring?: (call: MonitoringCall) => Response | undefined + /** Answer for one `searchAllResources` call; undefined answers "no resources". */ + readonly assets?: (call: ScopedCall) => Response | undefined + readonly ingestStatus?: number +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) + +const googleError = (status: number, code: string) => + json( + { error: { code: status, status: code, message: `details that must not be stored: ${code}` } }, + status, + ) + +/** One Cloud Run series with two points inside the first poll's window. */ +const requestCountSeries = (projectId = "acme-prod") => ({ + timeSeries: [ + { + metric: { type: REQUEST_COUNT, labels: { response_code_class: "2xx" } }, + resource: { + type: "cloud_run_revision", + labels: { project_id: projectId, service_name: "checkout", location: "europe-west4" }, + }, + metricKind: "DELTA", + valueType: "INT64", + points: [ + { + interval: { startTime: "2026-10-08T12:23:00Z", endTime: "2026-10-08T12:24:00Z" }, + value: { int64Value: "12" }, + }, + { + interval: { startTime: "2026-10-08T12:22:00Z", endTime: "2026-10-08T12:23:00Z" }, + value: { int64Value: "30" }, + }, + ], + }, + ], +}) + +const onlyRequestCount = (call: MonitoringCall) => + call.metricType === REQUEST_COUNT ? json(requestCountSeries()) : undefined + +const runService = (name: string, projectId = "acme-prod") => ({ + name: `//run.googleapis.com/projects/${projectId}/locations/europe-west4/services/${name}`, + assetType: "run.googleapis.com/Service", + displayName: name, + location: "europe-west4", + labels: { team: "payments" }, + createTime: "2026-01-02T03:04:05Z", + updateTime: "2026-10-01T00:00:00Z", +}) + +const project = (projectId: string, number: string) => ({ + name: `//cloudresourcemanager.googleapis.com/projects/${number}`, + assetType: "cloudresourcemanager.googleapis.com/Project", + displayName: projectId, + state: "ACTIVE", + additionalAttributes: { projectId }, +}) + +const stubFetch = (calls: Calls, options: StubOptions = {}) => + (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL( + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, + ) + const authorization = new Headers(init?.headers).get("authorization") + const body = await new Response(init?.body ?? null).text() + if (url.host === "oauth2.googleapis.com") { + calls.oauth.push(new URLSearchParams(body).get("assertion") ?? "") + return options.oauth?.() ?? json({ access_token: MAPLE_TOKEN, expires_in: 600 }) + } + if (url.host === "iamcredentials.googleapis.com") { + const account = decodeURIComponent( + url.pathname + .replace("/v1/projects/-/serviceAccounts/", "") + .replace(":generateAccessToken", ""), + ) + calls.impersonate.push({ account, authorization, body: JSON.parse(body) }) + return options.impersonate?.(account) ?? json({ accessToken: READER_TOKEN }) + } + if (url.host === "monitoring.googleapis.com") { + const filter = url.searchParams.get("filter") ?? "" + const call = { + scope: url.pathname.replace("/v3/", "").replace("/timeSeries", ""), + metricType: /metric\.type = "([^"]+)"/.exec(filter)?.[1] ?? "", + params: url.searchParams, + authorization, + } + calls.monitoring.push(call) + return options.monitoring?.(call) ?? json({}) + } + if (url.host === "cloudasset.googleapis.com") { + const call = { + scope: url.pathname.replace("/v1/", "").replace(":searchAllResources", ""), + params: url.searchParams, + authorization, + } + calls.assets.push(call) + return options.assets?.(call) ?? json({}) + } + if (url.host === "ingest.test") { + calls.ingest.push({ authorization, body }) + return json({}, options.ingestStatus ?? 200) + } + throw new Error(`unexpected request to ${url.host}`) + }) as typeof fetch + +const makeCalls = (): Calls => ({ oauth: [], impersonate: [], monitoring: [], assets: [], ingest: [] }) + +/** Everything a tick logs or traces, as one string to search for secrets. */ +const makeRecorder = () => { + const spans: Array = [] + const logs: Array = [] + const tracer = Tracer.make({ + span(options) { + const span = new Tracer.NativeSpan(options) + spans.push(span) + return span + }, + }) + const logger = Logger.make(({ fiber, message }) => { + logs.push(JSON.stringify([message, fiber.getRef(References.CurrentLogAnnotations)])) + }) + /** Spans that ended in a failure: what an exporter would report as an error. */ + const failedSpans = () => + spans.flatMap((span) => + span.status._tag === "Ended" && Exit.isFailure(span.status.exit) + ? [`${span.name}: ${String(span.status.exit.cause)}`] + : [], + ) + return { + tracer, + logger, + failedSpans, + text: () => + JSON.stringify([ + logs, + spans.map((span) => [span.name, [...span.attributes], span.events]), + failedSpans(), + ]), + } +} + +const run = ( + testDb: TestDb, + stub: typeof fetch, + effect: Effect.Effect, + options: { readonly key?: string | null; readonly recorder?: ReturnType } = {}, +) => { + const key = options.key === undefined ? encodedKey : options.key + const recorder = options.recorder ?? makeRecorder() + return Effect.gen(function* () { + yield* TestClock.setTime(now) + return yield* effect + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, stub), + Effect.provide( + Layer.effect(GcpMetricsService, GcpMetricsService.make).pipe( + Layer.provide(Layer.mergeAll(FetchHttpClient.layer, OrgIngestKeysService.layer)), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, stub)), + Layer.provide(testDb.layer), + Layer.provide(Env.layer), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + 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: Buffer.alloc(32, 9).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_INGEST_PUBLIC_URL: "https://ingest.test", + ...(key === null ? undefined : { MAPLE_GCP_SERVICE_ACCOUNT_KEY: key }), + }), + ), + ), + ), + ), + Effect.provideService(Tracer.Tracer, recorder.tracer), + Effect.provideService(Logger.CurrentLoggers, new Set([recorder.logger])), + ) +} + +const insertConnector = ( + testDb: TestDb, + id: string, + options: { + readonly orgId?: string + /** The host project, and the scope unless `scope` says otherwise. */ + readonly projectId?: string + readonly scope?: readonly [type: "folder" | "organization", id: string] + readonly metricsEnabled?: boolean + } = {}, +) => { + const projectId = options.projectId ?? "acme-prod" + const [scopeType, scopeId] = options.scope ?? ["project", projectId] + return Effect.promise(() => + executeSql( + testDb, + `INSERT INTO gcp_connectors + (id, org_id, scope_type, scope_id, project_id, secret_ciphertext, secret_iv, secret_tag, + secret_hash, metrics_enabled, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'c', 'i', 't', $1, $6, 'user_1', now(), now())`, + [id, options.orgId ?? "org_gcp", scopeType, scopeId, projectId, options.metricsEnabled ?? true], + ), + ) +} + +interface PollState { + readonly metrics_watermark_at: Date | null + readonly last_metrics_received_at: Date | null + readonly last_metrics_error: string | null + readonly metrics_lease_until: Date | null + readonly resources_synced_at: Date | null + readonly last_resources_error: string | null +} + +const pollState = (testDb: TestDb, id: string) => + Effect.promise(() => + queryFirstRow( + testDb, + `SELECT metrics_watermark_at, last_metrics_received_at, last_metrics_error, metrics_lease_until, + resources_synced_at, last_resources_error + FROM gcp_connectors WHERE id = $1`, + [id], + ), + ).pipe(Effect.map((row) => row!)) + +/** The connector's inventory as `asset type -> display name` pairs, in name order. */ +const inventory = (testDb: TestDb, id: string) => + Effect.promise(() => + queryFirstRow<{ readonly resources: ReadonlyArray> | null }>( + testDb, + `SELECT json_agg(json_build_array(project_id, asset_type, display_name) ORDER BY name) AS resources + FROM gcp_resources WHERE connector_id = $1`, + [id], + ), + ).pipe(Effect.map((row) => row?.resources ?? [])) + +const pollAll = GcpMetricsService.use((gcp) => gcp.pollAll()) + +const decodeJwtPart = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString("utf8")) + +describe("GcpMetricsService", () => { + it.effect("signs in as Maple, impersonates the connector's reader and ships its metrics", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const recorder = makeRecorder() + return run( + testDb, + stubFetch(calls, { monitoring: onlyRequestCount }), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const summary = yield* pollAll + assert.deepInclude(summary, { polled: 1, deferred: 0, rowsIngested: 2, failures: 0 }) + // Sign-in, impersonation, inventory page, one query per curated metric, ingest post. + assert.strictEqual(summary.calls, 4 + METRIC_COUNT) + + // The assertion is an RS256 JWT from Maple's account, valid for ten minutes. + assert.lengthOf(calls.oauth, 1) + const [header, claims, signature] = calls.oauth[0]!.split(".") as [string, string, string] + assert.deepStrictEqual(decodeJwtPart(header), { alg: "RS256", typ: "JWT", kid: "key-1" }) + assert.deepStrictEqual(decodeJwtPart(claims), { + iss: MAPLE_ACCOUNT, + scope: "https://www.googleapis.com/auth/iam", + aud: "https://oauth2.googleapis.com/token", + iat: Math.floor(now / 1000), + exp: Math.floor(now / 1000) + 600, + }) + assert.isTrue( + yield* Effect.promise(() => + crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + publicKey, + Buffer.from(signature, "base64url"), + new TextEncoder().encode(`${header}.${claims}`), + ), + ), + ) + + assert.deepStrictEqual(calls.impersonate, [ + { + account: READER_A, + authorization: `Bearer ${MAPLE_TOKEN}`, + body: { + scope: ["https://www.googleapis.com/auth/cloud-platform"], + lifetime: "600s", + }, + }, + ]) + + assert.lengthOf(calls.monitoring, METRIC_COUNT) + assert.isTrue( + calls.monitoring.every( + (call) => + call.scope === "projects/acme-prod" && + call.authorization === `Bearer ${READER_TOKEN}`, + ), + ) + const query = calls.monitoring.find((call) => call.metricType === REQUEST_COUNT)! + assert.deepStrictEqual(Object.fromEntries(query.params), { + filter: `metric.type = "${REQUEST_COUNT}" AND resource.type = "cloud_run_revision"`, + "interval.startTime": "2026-10-08T11:25:00.000Z", + "interval.endTime": "2026-10-08T12:25:00.000Z", + "aggregation.alignmentPeriod": "60s", + "aggregation.perSeriesAligner": "ALIGN_DELTA", + "aggregation.crossSeriesReducer": "REDUCE_SUM", + // Object.fromEntries keeps the last of a repeated parameter. + "aggregation.groupByFields": "metric.label.response_code_class", + view: "FULL", + pageSize: "5000", + }) + assert.deepStrictEqual(query.params.getAll("aggregation.groupByFields"), [ + "resource.label.project_id", + "resource.label.service_name", + "resource.label.location", + "metric.label.response_code_class", + ]) + + assert.lengthOf(calls.ingest, 1) + assert.match(calls.ingest[0]!.authorization ?? "", /^Bearer maple_pk_/) + const payload = JSON.parse(calls.ingest[0]!.body) + const resource = payload.resourceMetrics[0] + const attributes = Object.fromEntries( + resource.resource.attributes.map( + (attribute: { key: string; value: { stringValue: string } }) => [ + attribute.key, + attribute.value.stringValue, + ], + ), + ) + assert.deepInclude(attributes, { + "service.name": "checkout", + "cloud.provider": "gcp", + "cloud.account.id": "acme-prod", + }) + const metric = resource.scopeMetrics[0].metrics[0] + assert.strictEqual(metric.name, "gcp.run.request_count") + assert.strictEqual(metric.sum.aggregationTemporality, 1) + assert.deepStrictEqual( + metric.sum.dataPoints.map((point: { asDouble: number }) => point.asDouble), + [12, 30], + ) + + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + assert.strictEqual(state.last_metrics_received_at?.getTime(), now) + assert.isNull(state.last_metrics_error) + assert.strictEqual(state.metrics_lease_until?.getTime(), now + 4 * minute) + + // No credential in anything the tick logged or traced. + const recorded = recorder.text() + assert.include(recorded, "GcpMetricsService.pollConnector") + // The HTTP client's spans are in there, with the Authorization header redacted. + assert.include(recorded, "iamcredentials.googleapis.com") + assert.include(recorded, '["http.request.header.authorization",""]') + for (const secret of [MAPLE_TOKEN, READER_TOKEN, privateKeyBody.slice(0, 40), signature]) { + assert.notInclude(recorded, secret) + } + }), + { recorder }, + ) + }) + + it.effect("reads a whole organization in one query per metric, attributing each project", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { + monitoring: (call) => + call.metricType === REQUEST_COUNT + ? json({ + timeSeries: [ + ...requestCountSeries("shop-prod").timeSeries, + ...requestCountSeries("shop-staging").timeSeries, + ], + }) + : undefined, + assets: () => json({ results: [project("shop-prod", "111"), project("shop-staging", "222")] }), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + // The reader account lives in the host project; its roles are granted on the organization. + yield* insertConnector(testDb, CONNECTOR_A, { scope: ["organization", "123456789012"] }) + assert.deepInclude(yield* pollAll, { polled: 1, rowsIngested: 4, failures: 0 }) + assert.deepStrictEqual( + calls.impersonate.map((call) => call.account), + [READER_A], + ) + assert.lengthOf(calls.monitoring, METRIC_COUNT) + assert.isTrue(calls.monitoring.every((call) => call.scope === "organizations/123456789012")) + assert.deepStrictEqual( + calls.assets.map((call) => call.scope), + ["organizations/123456789012"], + ) + + const payload = JSON.parse(calls.ingest[0]!.body) + const accounts = payload.resourceMetrics.map( + (resource: { + resource: { attributes: Array<{ key: string; value: { stringValue: string } }> } + }) => + resource.resource.attributes.find((attribute) => attribute.key === "cloud.account.id") + ?.value.stringValue, + ) + assert.sameMembers(accounts, ["shop-prod", "shop-staging"]) + assert.deepStrictEqual(yield* inventory(testDb, CONNECTOR_A), [ + ["shop-prod", "cloudresourcemanager.googleapis.com/Project", "shop-prod"], + ["shop-staging", "cloudresourcemanager.googleapis.com/Project", "shop-staging"], + ]) + }), + ) + }) + + it.effect("advances the watermark tick by tick, and waits out a live lease", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + yield* pollAll + + // An overlapping tick a minute later finds the lease held and does nothing. + yield* TestClock.setTime(now + minute) + assert.deepInclude(yield* pollAll, { polled: 0, calls: 0 }) + assert.lengthOf(calls.oauth, 1) + + // The next cron tick reads exactly the five minutes that followed. + yield* TestClock.setTime(now + 5 * minute) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0 }) + const second = calls.monitoring.at(-1)!.params + assert.strictEqual(second.get("interval.startTime"), "2026-10-08T12:25:00.000Z") + assert.strictEqual(second.get("interval.endTime"), "2026-10-08T12:30:00.000Z") + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon + 5 * minute) + }), + ) + }) + + it.effect("polls only connectors with metrics enabled", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A, { metricsEnabled: false }) + assert.deepInclude(yield* pollAll, { polled: 0, deferred: 0, calls: 0 }) + assert.isNull((yield* pollState(testDb, CONNECTOR_A)).metrics_lease_until) + + yield* insertConnector(testDb, CONNECTOR_B, { projectId: "acme-staging" }) + assert.deepInclude(yield* pollAll, { polled: 1 }) + assert.deepStrictEqual( + calls.impersonate.map((call) => call.account), + ["maple-028f2b3c4d5e4f708192a3b4@acme-staging.iam.gserviceaccount.com"], + ) + }), + ) + }) + + it.effect("is a no-op without a service account key", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 0, calls: 0 }) + assert.deepStrictEqual(calls, makeCalls()) + assert.isNull((yield* pollState(testDb, CONNECTOR_A)).metrics_lease_until) + }), + { key: null }, + ) + }) + + it.effect("tells a scope that has not run the script what to do, for one call per tick", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const recorder = makeRecorder() + const stub = stubFetch(calls, { + impersonate: (account) => + account === READER_A ? googleError(403, "PERMISSION_DENIED") : undefined, + monitoring: onlyRequestCount, + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + yield* insertConnector(testDb, CONNECTOR_B, { orgId: "org_other", projectId: "other-prod" }) + const summary = yield* pollAll + // The denied connector does not hold back the other organization's. + assert.deepInclude(summary, { polled: 2, failures: 1, rowsIngested: 2 }) + assert.isTrue(calls.monitoring.every((call) => call.scope === "projects/other-prod")) + + const denied = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + denied.last_metrics_error, + "Google IAM returned 403 PERMISSION_DENIED. Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply.", + ) + assert.isNull(denied.metrics_watermark_at) + assert.isNull(denied.last_metrics_received_at) + + // Retried at tick cadence, and an expected state is not an exception on a span. + yield* TestClock.setTime(now + 5 * minute) + yield* pollAll + assert.lengthOf( + calls.impersonate.filter((call) => call.account === READER_A), + 2, + ) + assert.notInclude(recorder.text(), "must not be stored") + assert.deepStrictEqual(recorder.failedSpans(), []) + }), + { recorder }, + ) + }) + + it.effect("stops paging a metric at the cap and reports it", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { + monitoring: (call) => + call.metricType === REQUEST_COUNT + ? json({ ...requestCountSeries(), nextPageToken: `page-${calls.monitoring.length}` }) + : undefined, + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const summary = yield* pollAll + assert.deepInclude(summary, { polled: 1, failures: 0, incompleteMetrics: 1 }) + const pages = calls.monitoring.filter((call) => call.metricType === REQUEST_COUNT) + assert.lengthOf(pages, MAX_PAGES_PER_METRIC) + assert.deepStrictEqual( + pages.map((call) => call.params.get("pageToken")), + [ + null, + ...Array.from( + { length: MAX_PAGES_PER_METRIC - 1 }, + (_, index) => `page-${index + 1}`, + ), + ], + ) + // What was read is kept, and the window is not read again. + assert.strictEqual(summary.rowsIngested, 2 * MAX_PAGES_PER_METRIC) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + assert.strictEqual( + state.last_metrics_error, + `1 of ${METRIC_COUNT} metric queries returned more series than one poll reads; connect folders or projects separately to collect all of them.`, + ) + }), + ) + }) + + it.effect("stops reading further metrics once a poll has read its share of points", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + // 25,000 points per metric, all before the window: read and counted, nothing to store. + const heavy = JSON.stringify({ + timeSeries: Array.from({ length: 10 }, (_, index) => ({ + metric: {}, + resource: { labels: { project_id: `p-${index}` } }, + points: Array.from({ length: 2_500 }, () => ({ + interval: { endTime: "2026-10-08T10:00:00Z" }, + value: { doubleValue: 1 }, + })), + })), + }) + const stub = stubFetch(calls, { + monitoring: () => new Response(heavy, { headers: { "content-type": "application/json" } }), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const summary = yield* pollAll + // Four queries reach 100,000 points; the rest wait for a smaller scope. + assert.lengthOf(calls.monitoring, 4) + assert.deepInclude(summary, { polled: 1, failures: 0, incompleteMetrics: METRIC_COUNT - 4 }) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + assert.match( + state.last_metrics_error ?? "", + /^\d+ of \d+ metric queries returned more series/, + ) + }), + ) + }) + + it.effect("skips a metric Cloud Monitoring rejects and still ships the rest", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const rejected = "run.googleapis.com/request_latencies" + const stub = stubFetch(calls, { + monitoring: (call) => + call.metricType === rejected ? googleError(400, "INVALID_ARGUMENT") : onlyRequestCount(call), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) + assert.lengthOf(calls.monitoring, METRIC_COUNT) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_metrics_error, + `1 of ${METRIC_COUNT} metric queries failed. First: ${rejected}: Cloud Monitoring returned 400 INVALID_ARGUMENT.`, + ) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + }), + ) + }) + + it.effect("stops at the first denied query and keeps the window for the next tick", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { monitoring: () => googleError(403, "PERMISSION_DENIED") }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 1, rowsIngested: 0 }) + assert.lengthOf(calls.monitoring, 1) + assert.lengthOf(calls.ingest, 0) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.match( + state.last_metrics_error ?? "", + /^Cloud Monitoring returned 403 PERMISSION_DENIED\. Run the setup script/, + ) + assert.isNull(state.metrics_watermark_at) + }), + ) + }) + + it.effect("holds an organization that is over its plan limit for an hour", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + return run( + testDb, + stubFetch(calls, { monitoring: onlyRequestCount, ingestStatus: 402 }), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 1, rowsIngested: 0 }) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_metrics_error, + "Metrics are paused: this organization is over its plan limit.", + ) + assert.isNull(state.metrics_watermark_at) + assert.strictEqual(state.metrics_lease_until?.getTime(), now + 60 * minute) + + yield* TestClock.setTime(now + 30 * minute) + assert.deepInclude(yield* pollAll, { polled: 0, calls: 0 }) + }), + ) + }) + + it.effect("takes at most ten connectors of one organization per tick, the rest next time", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const connectorId = (index: number) => `0${index.toString(16)}8f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8` + const other = "ff8f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8" + return run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + for (let index = 0; index < 12; index++) { + yield* insertConnector(testDb, connectorId(index), { projectId: `acme-prod-${index}` }) + } + yield* insertConnector(testDb, other, { orgId: "org_other", projectId: "other-prod" }) + assert.deepInclude(yield* pollAll, { polled: 11, deferred: 2 }) + assert.isNotNull((yield* pollState(testDb, other)).metrics_watermark_at) + + // Never polled sorts first: the two that waited go before the ones that are due again. + yield* TestClock.setTime(now + 5 * minute) + const before = calls.impersonate.length + assert.deepInclude(yield* pollAll, { polled: 11, deferred: 2 }) + const order = calls.impersonate.slice(before).map((call) => call.account.split("@")[1]) + assert.sameMembers(order.slice(0, 2), [ + "acme-prod-10.iam.gserviceaccount.com", + "acme-prod-11.iam.gserviceaccount.com", + ]) + }), + ) + }) + + it.effect("fails the tick, without the key in the error, when Google rejects Maple's sign-in", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { + oauth: () => json({ error: "invalid_grant", error_description: "Invalid JWT Signature." }, 400), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const error = yield* Effect.flip(pollAll) + assert.strictEqual(error.message, "Google OAuth returned 400 invalid_grant") + assert.lengthOf(calls.impersonate, 0) + // Maple's own misconfiguration is not the customer's error. + assert.isNull((yield* pollState(testDb, CONNECTOR_A)).last_metrics_error) + }), + ) + }) + + it.effect("fails the tick on a key that is not a service account key, without quoting it", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const notAKey = Buffer.from(JSON.stringify({ private_key: "super-secret-material" })).toString( + "base64", + ) + return run( + testDb, + stubFetch(calls), + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const error = yield* Effect.flip(pollAll) + assert.strictEqual( + error.message, + "MAPLE_GCP_SERVICE_ACCOUNT_KEY is not a base64-encoded service account key", + ) + assert.notInclude(JSON.stringify(error), "super-secret-material") + assert.lengthOf(calls.oauth, 0) + }), + { key: notAKey }, + ) + }) +}) + +describe("GcpMetricsService resource inventory", () => { + it.effect("syncs the scope's resources hourly, replacing what changed and removing what is gone", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + let results: ReadonlyArray = [ + project("acme-prod", "111"), + runService("checkout"), + runService("cart"), + // Nothing to attribute it to: not stored. + { name: "//cloudresourcemanager.googleapis.com/folders/42", assetType: "x/Folder" }, + ] + const stub = stubFetch(calls, { + assets: (call) => + call.params.get("pageToken") === null + ? json({ results: results.slice(0, 2), nextPageToken: "next" }) + : json({ results: results.slice(2) }), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + yield* pollAll + assert.lengthOf(calls.assets, 2) + assert.strictEqual(calls.assets[0]!.authorization, `Bearer ${READER_TOKEN}`) + assert.deepStrictEqual(calls.assets[0]!.params.getAll("assetTypes"), [...GCP_ASSET_TYPES]) + assert.strictEqual(calls.assets[0]!.params.get("pageSize"), "500") + assert.strictEqual( + calls.assets[0]!.params.get("readMask"), + "name,assetType,displayName,location,state,labels,createTime,updateTime,additionalAttributes", + ) + assert.deepStrictEqual(yield* inventory(testDb, CONNECTOR_A), [ + ["acme-prod", "cloudresourcemanager.googleapis.com/Project", "acme-prod"], + ["acme-prod", "run.googleapis.com/Service", "cart"], + ["acme-prod", "run.googleapis.com/Service", "checkout"], + ]) + const synced = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(synced.resources_synced_at?.getTime(), now) + assert.isNull(synced.last_resources_error) + + // Metrics ticks in between leave the inventory alone. + results = [ + project("acme-prod", "111"), + { ...runService("checkout"), displayName: "Checkout" }, + ] + yield* TestClock.setTime(now + 55 * minute) + yield* pollAll + assert.lengthOf(calls.assets, 2) + + yield* TestClock.setTime(now + 60 * minute) + yield* pollAll + assert.lengthOf(calls.assets, 4) + assert.deepStrictEqual(yield* inventory(testDb, CONNECTOR_A), [ + ["acme-prod", "cloudresourcemanager.googleapis.com/Project", "acme-prod"], + ["acme-prod", "run.googleapis.com/Service", "Checkout"], + ]) + }), + ) + }) + + it.effect("reports a denied inventory on its own field, retries it, and still ships metrics", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { + monitoring: onlyRequestCount, + assets: () => googleError(403, "PERMISSION_DENIED"), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.match( + state.last_resources_error ?? "", + /^Cloud Asset Inventory returned 403 PERMISSION_DENIED\. Run the setup script/, + ) + assert.isNull(state.resources_synced_at) + assert.isNull(state.last_metrics_error) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + + yield* TestClock.setTime(now + 5 * minute) + yield* pollAll + assert.lengthOf(calls.assets, 2) + }), + ) + }) + + it.effect("keeps what it has when the scope holds more than one sync reads", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + let endless = false + const stub = stubFetch(calls, { + assets: () => + endless + ? json({ results: [runService(`svc-${calls.assets.length}`)], nextPageToken: "more" }) + : json({ results: [runService("checkout")] }), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + yield* pollAll + endless = true + yield* TestClock.setTime(now + 60 * minute) + yield* pollAll + assert.lengthOf(calls.assets, 1 + MAX_RESOURCE_PAGES) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_resources_error, + "The scope holds more than 20000 resources; the inventory is incomplete.", + ) + // Counted as synced, so the next attempt is in an hour, and nothing was removed. + assert.strictEqual(state.resources_synced_at?.getTime(), now + 60 * minute) + const names = (yield* inventory(testDb, CONNECTOR_A)).map((row) => row[2]) + assert.lengthOf(names, 1 + MAX_RESOURCE_PAGES) + assert.include(names, "checkout") + }), + ) + }) +}) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts new file mode 100644 index 0000000000..26c355beea --- /dev/null +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -0,0 +1,584 @@ +/** + * Google Cloud metrics and resource poller. On each alerting tick it reads the curated Cloud + * Monitoring metrics (`@maple/domain/gcp-metrics`) of every connector that has metrics enabled + * and ships them to the ingest gateway as OTLP, so routing, metering and durability are the + * gateway's. Hourly it also syncs the connector's resource inventory into `gcp_resources`. + * + * A connector covers one scope: a project, a folder or an organization. Both APIs are queried at + * that scope, so a poll costs about one call per curated metric however many projects it holds. + * Maple signs in as its own service account once per tick, then mints a short-lived token for + * each connector's reader account (the one the setup script created). + * + * Work per tick is bounded: connectors are taken least recently polled first, at most + * {@link MAX_CONNECTORS_PER_ORG_TICK} per organization, until a budget runs out. One that waits + * covers a longer window next time. A poll that reaches its own budget keeps what it read and + * moves on, and a metric query that fails twice is skipped for that window. The watermark stays + * only when nothing was shipped (no access, out of quota, nothing readable) or storing failed, so + * a window is ingested twice only if a poll dies between a flush and the watermark write. + */ +import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" +import { IntegrationsPersistenceError, UserId } from "@maple/domain/http" +import type { OrgId } from "@maple/domain/primitives" +import { gcpConnectors, gcpResources, type GcpConnectorRow } from "@maple/db" +import { and, asc, eq, isNull, lt, or, sql } from "drizzle-orm" +import { Cause, Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" +import { Database } from "@maple/backend/platform/DatabaseLive" +import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" +import { summarizeCause } from "@maple/backend/platform/describe-cause" +import { Env } from "@maple/backend/platform/Env" +import { dateToMs, msToDate } from "@maple/backend/platform/time" +import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" +import { metricRowsToOtlp } from "./cloudflare-analytics/otlp" +import { + fetchMapleAccessToken, + GcpApiError, + importServiceAccountKey, + impersonateReader, + listTimeSeriesPage, + searchResourcesPage, + type GcpReader, + type GcpTimeSeriesQuery, +} from "./gcp/api" +import { mapGcpResource, mapGcpTimeSeries, type GcpMetricRows } from "./gcp/mapping" + +const MINUTE_MS = 60_000 +/** The curated metrics document up to 240 s between a sample and its visibility. */ +const INGESTION_LAG_MS = 5 * MINUTE_MS +/** How far back the first poll starts, and the most one poll catches up. */ +const MAX_WINDOW_MS = 60 * MINUTE_MS +/** + * A tick must finish inside its lease. No connector starts after the tick budget, a poll stops + * reading at its own budget and is cut off at the timeout, so the last one ends 3.5 minutes in. + */ +const LEASE_MS = 4 * MINUTE_MS +const TICK_BUDGET_MS = 1.5 * MINUTE_MS +const POLL_BUDGET_MS = MINUTE_MS +const CONNECTOR_TIMEOUT = Duration.minutes(2) +const INGEST_TIMEOUT = Duration.seconds(30) +const RESOURCE_SYNC_TIMEOUT = Duration.seconds(30) +/** The alerting Worker gets 10,000 subrequests per invocation, shared with the other ticks. */ +const MAX_CALLS_PER_TICK = 3_000 +/** Read caps, sized so a tick's decoding and re-encoding stays a few CPU seconds of the cron's 30. */ +export const MAX_PAGES_PER_METRIC = 10 +const MAX_POINTS_PER_POLL = 100_000 +const MAX_POINTS_PER_TICK = 300_000 +const MAX_CONNECTORS_PER_ORG_TICK = 10 +const CONNECTOR_CONCURRENCY = 3 +/** Rows buffered per connector before they are shipped. */ +const FLUSH_ROWS = 5_000 +/** An organization over its plan limit is not read again for this long. */ +const BILLING_HOLD_MS = 60 * MINUTE_MS +const RESOURCE_SYNC_INTERVAL_MS = 60 * MINUTE_MS +/** 500 resources per page. */ +export const MAX_RESOURCE_PAGES = 20 + +const METRICS = GCP_METRIC_GROUPS.flatMap((group) => group.metrics.map((metric) => ({ group, metric }))) + +const SYSTEM_USER_ID = Schema.decodeUnknownSync(UserId)("system-gcp-metrics") + +const SETUP_HINT = + "Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply." + +const describeApiError = (error: GcpApiError) => + error.kind === "denied" || error.kind === "not_found" ? `${error.message}. ${SETUP_HINT}` : error.message + +const toPersistenceError = makePersistenceErrorMapper( + IntegrationsPersistenceError, + "Google Cloud metrics database error", +) + +class GcpMetricsIngestError extends Schema.TaggedError()( + "@maple/api/integrations/GcpMetricsIngestError", + { message: Schema.String, status: Schema.optionalKey(Schema.Number) }, +) {} + +/** Next window for a connector, on minute boundaries, or null when it is caught up. */ +export const nextWindow = (watermarkAt: Date | null, now: number) => { + const horizonMs = now - INGESTION_LAG_MS - ((now - INGESTION_LAG_MS) % MINUTE_MS) + const startMs = watermarkAt === null ? horizonMs - MAX_WINDOW_MS : dateToMs(watermarkAt) + // Cap the end, not the start: a long gap catches up one window per poll instead of being skipped. + const endMs = Math.min(horizonMs, startMs + MAX_WINDOW_MS) + return startMs < endMs ? { startMs, endMs } : null +} + +export interface GcpMetricsPollSummary { + /** Connectors polled this tick. */ + readonly polled: number + /** Due connectors left for a later tick: over a cap or budget, or claimed by an overlapping tick. */ + readonly deferred: number + readonly rowsIngested: number + readonly failures: number + /** Metric queries cut short or left unread at a per-poll budget. */ + readonly incompleteMetrics: number + readonly calls: number +} + +const IDLE: GcpMetricsPollSummary = { + polled: 0, + deferred: 0, + rowsIngested: 0, + failures: 0, + incompleteMetrics: 0, + calls: 0, +} + +interface Tick { + readonly now: number + /** Counts every outbound request into `budget`. */ + readonly httpClient: HttpClient.HttpClient + readonly mapleToken: Redacted.Redacted + readonly budget: { calls: number; points: number } +} + +interface ConnectorOutcome { + readonly rowsIngested: number + readonly failed: boolean + readonly incompleteMetrics: number +} + +export interface GcpMetricsServiceApi { + /** No-op unless `MAPLE_GCP_SERVICE_ACCOUNT_KEY` is set. */ + readonly pollAll: () => Effect.Effect +} + +export class GcpMetricsService extends Context.Service()( + "@maple/api/services/GcpMetricsService", + { + make: Effect.gen(function* () { + const database = yield* Database + const env = yield* Env + const baseHttpClient = yield* HttpClient.HttpClient + const ingestKeys = yield* OrgIngestKeysService + const ingestMetricsUrl = `${env.MAPLE_INGEST_PUBLIC_URL.replace(/\/+$/, "")}/v1/metrics` + const dbExecute = makeDbExecute(database, "GcpMetricsService", toPersistenceError) + + const updateConnector = ( + id: GcpConnectorRow["id"], + set: Partial, + ) => dbExecute((db) => db.update(gcpConnectors).set(set).where(eq(gcpConnectors.id, id))) + + /** A failed poll leaves the watermark alone and says why on the connector. */ + const recordFailure = Effect.fnUntraced(function* ( + connector: GcpConnectorRow, + now: number, + message: string, + holdMs?: number, + ) { + yield* Effect.annotateCurrentSpan({ "maple.gcp.poll_failed": true }) + yield* Effect.logWarning("gcp connector poll failed", { + orgId: connector.orgId, + connectorId: connector.id, + error: message, + }) + yield* updateConnector(connector.id, { + lastMetricsError: message, + ...(holdMs === undefined ? undefined : { metricsLeaseUntil: msToDate(now + holdMs) }), + }) + return { rowsIngested: 0, failed: true, incompleteMetrics: 0 } satisfies ConnectorOutcome + }) + + const emitMetrics = Effect.fnUntraced( + function* (httpClient: HttpClient.HttpClient, ingestKey: string, rows: GcpMetricRows) { + const total = rows.sumRows.length + rows.gaugeRows.length + if (total === 0) return 0 + const response = yield* httpClient + .execute( + HttpClientRequest.post(ingestMetricsUrl).pipe( + HttpClientRequest.bearerToken(ingestKey), + HttpClientRequest.bodyJsonUnsafe( + metricRowsToOtlp(rows.sumRows, rows.gaugeRows), + ), + ), + ) + .pipe( + Effect.annotateSpans("peer.service", "ingest"), + Effect.mapError( + () => new GcpMetricsIngestError({ message: "Metrics ingest request failed" }), + ), + ) + if (response.status >= 300) { + return yield* new GcpMetricsIngestError({ + message: `Metrics ingest returned ${response.status}`, + status: response.status, + }) + } + return total + }, + Effect.timeoutOrElse({ + duration: INGEST_TIMEOUT, + orElse: () => + Effect.fail(new GcpMetricsIngestError({ message: "Metrics ingest timed out" })), + }), + ) + + /** + * Replace the connector's inventory with what the scope holds now. Returns why it is + * incomplete, or null. Rows the sync did not return are removed only once every page + * was read. + */ + const syncResources = Effect.fnUntraced(function* ( + connector: GcpConnectorRow, + tick: Tick, + scope: string, + reader: GcpReader, + ) { + const lastSeenAt = msToDate(tick.now) + let pageToken: string | undefined + for (let page = 0; page < MAX_RESOURCE_PAGES; page++) { + const body = yield* searchResourcesPage(tick.httpClient, reader, scope, pageToken) + // Keyed by name: one statement cannot upsert the same row twice. + const rows = new Map( + (body.results ?? []).flatMap((result) => { + const resource = mapGcpResource(result) + return resource === undefined ? [] : [[resource.name, resource] as const] + }), + ) + if (rows.size > 0) { + yield* dbExecute((db) => + db + .insert(gcpResources) + .values( + [...rows.values()].map((resource) => ({ + ...resource, + connectorId: connector.id, + orgId: connector.orgId, + lastSeenAt, + })), + ) + .onConflictDoUpdate({ + target: [gcpResources.connectorId, gcpResources.name], + set: { + assetType: sql`excluded.asset_type`, + projectId: sql`excluded.project_id`, + location: sql`excluded.location`, + displayName: sql`excluded.display_name`, + state: sql`excluded.state`, + labels: sql`excluded.labels`, + resourceCreatedAt: sql`excluded.resource_created_at`, + resourceUpdatedAt: sql`excluded.resource_updated_at`, + lastSeenAt: sql`excluded.last_seen_at`, + }, + }), + ) + } + pageToken = body.nextPageToken + if (pageToken === undefined) { + yield* dbExecute((db) => + db + .delete(gcpResources) + .where( + and( + eq(gcpResources.connectorId, connector.id), + lt(gcpResources.lastSeenAt, lastSeenAt), + ), + ), + ) + return null + } + } + return `The scope holds more than ${MAX_RESOURCE_PAGES * 500} resources; the inventory is incomplete.` + }) + + /** + * The connector's inventory sync, as the columns to write. It has its own outcome: a + * sync that is denied or cannot be stored is retried next tick, one that runs out of + * pages or time waits for the next hour, and none of them holds the metrics back. + */ + const resourceSyncOutcome = ( + connector: GcpConnectorRow, + tick: Tick, + scope: string, + reader: GcpReader, + ) => + syncResources(connector, tick, scope, reader).pipe( + Effect.timeoutOrElse({ + duration: RESOURCE_SYNC_TIMEOUT, + orElse: () => Effect.succeed("The inventory sync ran out of time; it is incomplete."), + }), + Effect.map((note) => ({ + lastResourcesError: note, + resourcesSyncedAt: msToDate(tick.now), + })), + Effect.catchTags({ + "@maple/api/integrations/GcpApiError": (error) => + Effect.succeed({ lastResourcesError: describeApiError(error) }), + "@maple/http/errors/IntegrationsPersistenceError": () => + Effect.succeed({ lastResourcesError: "Maple could not store the inventory." }), + }), + ) + + /** + * One connector: its inventory sync when due, then its metrics window. Fails when the + * window could not be read or stored at all. + */ + const pollConnector = Effect.fn("GcpMetricsService.pollConnector")( + function* (connector: GcpConnectorRow, tick: Tick) { + // The id is validated when the connector is created; encoding keeps it inside the path. + const scope = `${connector.scopeType}s/${encodeURIComponent(connector.scopeId)}` + yield* Effect.annotateCurrentSpan({ + orgId: connector.orgId, + "maple.gcp.connector_id": connector.id, + "maple.gcp.scope": scope, + }) + const idle: ConnectorOutcome = { rowsIngested: 0, failed: false, incompleteMetrics: 0 } + const window = nextWindow(connector.metricsWatermarkAt, tick.now) + const resourcesDue = + connector.resourcesSyncedAt === null || + tick.now - dateToMs(connector.resourcesSyncedAt) >= RESOURCE_SYNC_INTERVAL_MS + if (window === null && !resourcesDue) return idle + const startedAt = yield* Clock.currentTimeMillis + const outOfTime = Effect.map( + Clock.currentTimeMillis, + (time) => time - startedAt >= POLL_BUDGET_MS, + ) + const reader = yield* impersonateReader(tick.httpClient, tick.mapleToken, connector) + + if (resourcesDue) { + yield* updateConnector( + connector.id, + yield* resourceSyncOutcome(connector, tick, scope, reader), + ) + } + if (window === null) return idle + const ingestKey = (yield* ingestKeys.getOrCreate(connector.orgId, SYSTEM_USER_ID)) + .publicKey + + let buffer: GcpMetricRows = { sumRows: [], gaugeRows: [] } + let rowsIngested = 0 + let points = 0 + /** One metric, page by page; false when pages were left unread. */ + const readMetric = Effect.fnUntraced(function* (query: GcpTimeSeriesQuery) { + let pageToken: string | undefined + for (let page = 0; page < MAX_PAGES_PER_METRIC; page++) { + if (page > 0 && (yield* outOfTime)) return false + const body = yield* listTimeSeriesPage( + tick.httpClient, + reader, + query, + pageToken, + ).pipe( + // One more try rides out a blip; a query that keeps failing is skipped. + Effect.retry({ times: 1, while: (error) => error.kind === "upstream" }), + ) + const series = body.timeSeries ?? [] + const read = series.reduce((sum, item) => sum + (item.points?.length ?? 0), 0) + points += read + tick.budget.points += read + const rows = mapGcpTimeSeries(query, series) + buffer.sumRows.push(...rows.sumRows) + buffer.gaugeRows.push(...rows.gaugeRows) + if (buffer.sumRows.length + buffer.gaugeRows.length >= FLUSH_ROWS) { + rowsIngested += yield* emitMetrics(tick.httpClient, ingestKey, buffer) + buffer = { sumRows: [], gaugeRows: [] } + } + pageToken = body.nextPageToken + if (pageToken === undefined) return true + } + return false + }) + + let succeeded = 0 + let incomplete = 0 + let firstFailure: { readonly type: string; readonly error: GcpApiError } | undefined + for (const { group, metric } of METRICS) { + if (points >= MAX_POINTS_PER_POLL || (yield* outOfTime)) { + incomplete += 1 + continue + } + const result = yield* readMetric({ scope, group, metric, ...window }).pipe( + Effect.catchTag("@maple/api/integrations/GcpApiError", (error) => + Effect.succeed(error), + ), + ) + if (result === true) { + succeeded += 1 + } else if (result === false) { + incomplete += 1 + } else { + firstFailure ??= { type: metric.type, error: result } + if (result.kind === "denied" || result.kind === "rate_limited") { + // Every remaining query would fail the same way. With nothing + // shipped yet, the next poll repeats the whole window instead. + if (rowsIngested === 0) return yield* result + break + } + } + } + if (firstFailure !== undefined && succeeded + incomplete === 0) { + return yield* firstFailure.error + } + rowsIngested += yield* emitMetrics(tick.httpClient, ingestKey, buffer) + + const failed = METRICS.length - succeeded - incomplete + const notes = [ + ...(firstFailure === undefined + ? [] + : [ + `${failed} of ${METRICS.length} metric queries failed. First: ${firstFailure.type}: ${firstFailure.error.message}.`, + ]), + ...(incomplete === 0 + ? [] + : [ + `${incomplete} of ${METRICS.length} metric queries held more than one poll reads; connect folders or projects separately to collect all of it.`, + ]), + ] + if (incomplete > 0) { + yield* Effect.logWarning("gcp connector poll reached a read budget", { + orgId: connector.orgId, + connectorId: connector.id, + incompleteMetrics: incomplete, + points, + }) + } + yield* updateConnector(connector.id, { + metricsWatermarkAt: msToDate(window.endMs), + lastMetricsReceivedAt: msToDate(tick.now), + lastMetricsError: notes.length === 0 ? null : notes.join(" "), + }) + yield* Effect.annotateCurrentSpan({ + "maple.gcp.rows_ingested": rowsIngested, + "maple.gcp.points_read": points, + "maple.gcp.metric_queries_failed": failed, + "maple.gcp.metric_queries_incomplete": incomplete, + }) + return { + rowsIngested, + failed: false, + incompleteMetrics: incomplete, + } satisfies ConnectorOutcome + }, + // Handled inside the span: a scope that is not set up, or an organization over its + // plan limit, is a recorded state, not an exception on Maple's own traces. + (effect, connector, tick) => + effect.pipe( + Effect.timeoutOrElse({ + duration: CONNECTOR_TIMEOUT, + orElse: () => + recordFailure( + connector, + tick.now, + "Reading the metrics took too long. Maple retries on the next poll.", + ), + }), + Effect.catchTags({ + "@maple/api/integrations/GcpApiError": (error) => + recordFailure(connector, tick.now, describeApiError(error)), + "@maple/api/integrations/GcpMetricsIngestError": (error) => + error.status === 402 + ? recordFailure( + connector, + tick.now, + "Metrics are paused: this organization is over its plan limit.", + BILLING_HOLD_MS, + ) + : recordFailure(connector, tick.now, error.message), + }), + ), + ) + + const pollAll = Effect.fn("GcpMetricsService.pollAll")(function* () { + const encodedKey = env.MAPLE_GCP_SERVICE_ACCOUNT_KEY + if (Option.isNone(encodedKey)) return IDLE + const now = yield* Clock.currentTimeMillis + const isDue = and( + eq(gcpConnectors.metricsEnabled, true), + or( + isNull(gcpConnectors.metricsLeaseUntil), + lt(gcpConnectors.metricsLeaseUntil, msToDate(now)), + ), + ) + // Two short columns per connector; the per-organization cap is applied below. + const due = yield* dbExecute((db) => + db + .select({ id: gcpConnectors.id, orgId: gcpConnectors.orgId }) + .from(gcpConnectors) + .where(isDue) + .orderBy( + sql`${gcpConnectors.metricsLeaseUntil} asc nulls first`, + asc(gcpConnectors.id), + ), + ) + const takenPerOrg = new Map() + const candidates = due.filter((row) => { + const taken = takenPerOrg.get(row.orgId) ?? 0 + takenPerOrg.set(row.orgId, taken + 1) + return taken < MAX_CONNECTORS_PER_ORG_TICK + }) + if (candidates.length === 0) return IDLE + + const budget = { calls: 0, points: 0 } + const httpClient = baseHttpClient.pipe( + HttpClient.tapRequest(() => + Effect.sync(() => { + budget.calls += 1 + }), + ), + ) + const signer = yield* importServiceAccountKey(encodedKey.value) + const tick: Tick = { + now, + httpClient, + mapleToken: yield* fetchMapleAccessToken(httpClient, signer, now), + budget, + } + + const outcomes = yield* Effect.forEach( + candidates, + (candidate) => + Effect.gen(function* () { + if ( + budget.calls >= MAX_CALLS_PER_TICK || + budget.points >= MAX_POINTS_PER_TICK || + (yield* Clock.currentTimeMillis) - now >= TICK_BUDGET_MS + ) { + return null + } + const [connector] = yield* dbExecute((db) => + db + .update(gcpConnectors) + .set({ metricsLeaseUntil: msToDate(now + LEASE_MS) }) + .where(and(eq(gcpConnectors.id, candidate.id), isDue)) + .returning(), + ) + return connector === undefined ? null : yield* pollConnector(connector, tick) + }).pipe( + // One connector's database or ingest-key failure must not fail the tick. + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("gcp connector poll failed", { + orgId: candidate.orgId, + connectorId: candidate.id, + error: summarizeCause(cause), + }).pipe( + Effect.as({ + rowsIngested: 0, + failed: true, + incompleteMetrics: 0, + }), + ), + ), + ), + { concurrency: CONNECTOR_CONCURRENCY }, + ) + const polled = outcomes.filter((outcome) => outcome !== null) + return { + polled: polled.length, + deferred: due.length - polled.length, + rowsIngested: polled.reduce((sum, outcome) => sum + outcome.rowsIngested, 0), + failures: polled.filter((outcome) => outcome.failed).length, + incompleteMetrics: polled.reduce((sum, outcome) => sum + outcome.incompleteMetrics, 0), + calls: budget.calls, + } satisfies GcpMetricsPollSummary + }) + + return { pollAll } satisfies GcpMetricsServiceApi + }), + }, +) { + static readonly layer = Layer.effect(this, this.make).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(OrgIngestKeysService.layer), + ) +} diff --git a/packages/backend/src/services/integrations/gcp/api.ts b/packages/backend/src/services/integrations/gcp/api.ts new file mode 100644 index 0000000000..acd76276fa --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/api.ts @@ -0,0 +1,379 @@ +// BOUNDARY: This module owns unparsed external values and narrows them before domain use. +/** + * The Google calls the metrics poller makes, over WebCrypto and `fetch` so they run in a Worker: + * sign in as Maple's service account, mint a short-lived token for the customer's reader + * account, and read Cloud Monitoring and Cloud Asset Inventory with it. + * + * Nothing here may leak a credential. Tokens stay `Redacted`, and a failure is described by the + * HTTP status and Google's error code only: never the response body, the transport error (it + * holds the request and its Authorization header) or a schema issue (it quotes its input). + * + * Untraced on purpose: the HTTP client already records a span per request, and a scope that is + * not set up yet is an expected state, not an exception on Maple's own traces. + */ +import { gcpConnectorResourceNames } from "@maple/domain/gcp" +import { GCP_ASSET_TYPES, type GcpMetric, type GcpMetricGroup } from "@maple/domain/gcp-metrics" +import type { GcpConnectorId, GcpProjectId } from "@maple/domain/primitives" +import { Duration, Effect, Option, Redacted, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/http" + +const TOKEN_URL = "https://oauth2.googleapis.com/token" +const IAM_SCOPE = "https://www.googleapis.com/auth/iam" +// Cloud Asset Inventory accepts no narrower scope. What the token can do is bounded by the +// reader account's roles, which the setup script keeps to read-only viewers. +const READER_SCOPE = "https://www.googleapis.com/auth/cloud-platform" +/** Lifetime of both tokens. A tick is done long before. */ +const TOKEN_LIFETIME_SECONDS = 600 +const REQUEST_TIMEOUT = Duration.seconds(20) +/** + * Points per `timeSeries.list` page: about 0.3 MB of JSON, 1 MB for distributions, so three + * connectors decoding a page each stay far below the Worker's 128 MB. + */ +const TIME_SERIES_PAGE_SIZE = 2_000 +/** Cloud Asset Inventory's maximum. */ +const RESOURCE_PAGE_SIZE = 500 + +export class GcpApiError extends Schema.TaggedError()("@maple/api/integrations/GcpApiError", { + message: Schema.String, + /** + * `denied`: 401/403. `not_found`: 404. `rate_limited`: 429. `invalid`: any other 4xx. + * `upstream`: 5xx, transport, timeout or an undecodable body. + */ + kind: Schema.Literals(["denied", "not_found", "rate_limited", "invalid", "upstream"]), +}) {} + +const errorKind = (status: number): GcpApiError["kind"] => + status === 401 || status === 403 + ? "denied" + : status === 404 + ? "not_found" + : status === 429 + ? "rate_limited" + : status < 500 + ? "invalid" + : "upstream" + +// Google APIs answer `{ error: { status: "PERMISSION_DENIED" } }`, the OAuth endpoint +// `{ error: "invalid_grant" }`. Only a value that reads like one of those codes is kept. +const decodeErrorCode = Schema.decodeUnknownOption( + Schema.Struct({ + error: Schema.Union([Schema.String, Schema.Struct({ status: Schema.String })]), + }), +) + +const errorCode = (body: unknown): string => { + const decoded = decodeErrorCode(body) + if (Option.isNone(decoded)) return "" + const error = decoded.value.error + const code = typeof error === "string" ? error : error.status + return /^[A-Za-z_]{1,40}$/.test(code) ? ` ${code}` : "" +} + +/** One JSON call. `api` names the Google service in the error message. */ +const call = ( + httpClient: HttpClient.HttpClient, + api: string, + request: HttpClientRequest.HttpClientRequest, + decode: (body: unknown) => Effect.Effect, +): Effect.Effect => { + const failed = (what: string) => () => + Effect.fail(new GcpApiError({ message: `${api} ${what}`, kind: "upstream" })) + return Effect.gen(function* () { + const response = yield* httpClient.execute(request) + const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)) + if (response.status >= 300) { + return yield* new GcpApiError({ + message: `${api} returned ${response.status}${errorCode(body)}`, + kind: errorKind(response.status), + }) + } + return yield* decode(body) + }).pipe( + Effect.timeout(REQUEST_TIMEOUT), + Effect.catchTags({ + HttpClientError: failed("request failed"), + TimeoutError: failed("request timed out"), + SchemaError: failed("answered with an unexpected body"), + }), + Effect.annotateSpans("peer.service", "google-cloud"), + ) +} + +// Maple's service account + +const ServiceAccountKey = Schema.Struct({ + client_email: Schema.String, + private_key: Schema.String, + private_key_id: Schema.String, +}) +const decodeServiceAccountKey = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceAccountKey)) + +const pemToPkcs8 = (pem: string): ArrayBuffer => { + const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64") + return der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) +} + +/** `MAPLE_GCP_SERVICE_ACCOUNT_KEY`: the service account's key file, base64-encoded. */ +export const importServiceAccountKey = (encoded: Redacted.Redacted) => + decodeServiceAccountKey(Buffer.from(Redacted.value(encoded), "base64").toString("utf8")).pipe( + Effect.flatMap((key) => + Effect.tryPromise(() => + crypto.subtle.importKey( + "pkcs8", + pemToPkcs8(key.private_key), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["sign"], + ), + ).pipe( + Effect.map((signingKey) => ({ + clientEmail: key.client_email, + keyId: key.private_key_id, + key: signingKey, + })), + ), + ), + Effect.mapError( + () => + new GcpApiError({ + message: "MAPLE_GCP_SERVICE_ACCOUNT_KEY is not a base64-encoded service account key", + kind: "invalid", + }), + ), + ) + +const base64Url = (value: string | ArrayBuffer) => + (typeof value === "string" ? Buffer.from(value, "utf8") : Buffer.from(value)).toString("base64url") + +const decodeAccessToken = Schema.decodeUnknownEffect( + Schema.Struct({ access_token: Schema.RedactedFromValue(Schema.String) }), +) + +/** Maple's own access token: an RS256-signed JWT exchanged at Google's OAuth endpoint. */ +export const fetchMapleAccessToken = Effect.fnUntraced(function* ( + httpClient: HttpClient.HttpClient, + signer: Effect.Success>, + nowMs: number, +) { + const issuedAt = Math.floor(nowMs / 1000) + const signingInput = [ + { alg: "RS256", typ: "JWT", kid: signer.keyId }, + { + iss: signer.clientEmail, + scope: IAM_SCOPE, + aud: TOKEN_URL, + iat: issuedAt, + exp: issuedAt + TOKEN_LIFETIME_SECONDS, + }, + ] + .map((part) => base64Url(JSON.stringify(part))) + .join(".") + const signature = yield* Effect.tryPromise(() => + crypto.subtle.sign("RSASSA-PKCS1-v1_5", signer.key, new TextEncoder().encode(signingInput)), + ).pipe( + Effect.mapError( + () => new GcpApiError({ message: "Signing the Google OAuth assertion failed", kind: "invalid" }), + ), + ) + const response = yield* call( + httpClient, + "Google OAuth", + HttpClientRequest.post(TOKEN_URL).pipe( + HttpClientRequest.bodyUrlParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: `${signingInput}.${base64Url(signature)}`, + }), + ), + decodeAccessToken, + ) + return response.access_token +}) + +const decodeImpersonatedToken = Schema.decodeUnknownEffect( + Schema.Struct({ accessToken: Schema.RedactedFromValue(Schema.String) }), +) + +/** A connector's reader account, signed in. Its project pays the quota for what it reads. */ +export interface GcpReader { + readonly accessToken: Redacted.Redacted + readonly quotaProject: GcpProjectId +} + +const asReader = (reader: GcpReader) => (request: HttpClientRequest.HttpClientRequest) => + request.pipe( + HttpClientRequest.bearerToken(reader.accessToken), + HttpClientRequest.setHeader("x-goog-user-project", reader.quotaProject), + ) + +/** + * A short-lived token for one connector's reader account: the service account the setup script + * created in the connector's project. The account is derived from the connector here, so no + * caller can name another organization's account. + */ +export const impersonateReader = Effect.fnUntraced(function* ( + httpClient: HttpClient.HttpClient, + mapleToken: Redacted.Redacted, + connector: { readonly id: GcpConnectorId; readonly projectId: GcpProjectId }, +) { + // A project id has nothing to encode; encoding keeps any other stored value inside the path. + const account = `${gcpConnectorResourceNames(connector.id).serviceAccountId}@${encodeURIComponent(connector.projectId)}.iam.gserviceaccount.com` + const response = yield* call( + httpClient, + "Google IAM", + HttpClientRequest.post( + `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${account}:generateAccessToken`, + ).pipe( + HttpClientRequest.bearerToken(mapleToken), + HttpClientRequest.bodyJsonUnsafe({ + scope: [READER_SCOPE], + lifetime: `${TOKEN_LIFETIME_SECONDS}s`, + }), + ), + decodeImpersonatedToken, + ) + return { accessToken: response.accessToken, quotaProject: connector.projectId } satisfies GcpReader +}) + +// Cloud Monitoring + +const BucketOptions = Schema.Struct({ + linearBuckets: Schema.optionalKey( + Schema.Struct({ + numFiniteBuckets: Schema.Number, + width: Schema.Number, + offset: Schema.optionalKey(Schema.Number), + }), + ), + exponentialBuckets: Schema.optionalKey( + Schema.Struct({ + numFiniteBuckets: Schema.Number, + growthFactor: Schema.Number, + scale: Schema.Number, + }), + ), + explicitBuckets: Schema.optionalKey( + Schema.Struct({ bounds: Schema.optionalKey(Schema.Array(Schema.Number)) }), + ), +}) + +const TimeSeries = Schema.Struct({ + metric: Schema.Struct({ labels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) }), + resource: Schema.Struct({ labels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) }), + points: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + interval: Schema.Struct({ endTime: Schema.String }), + // int64 values arrive as strings, and zero-valued fields are omitted. + value: Schema.Struct({ + doubleValue: Schema.optionalKey(Schema.Number), + int64Value: Schema.optionalKey(Schema.String), + distributionValue: Schema.optionalKey( + Schema.Struct({ + bucketOptions: Schema.optionalKey(BucketOptions), + bucketCounts: Schema.optionalKey(Schema.Array(Schema.String)), + }), + ), + }), + }), + ), + ), +}) +export type GcpTimeSeries = typeof TimeSeries.Type +export type GcpBucketOptions = typeof BucketOptions.Type + +const decodeTimeSeriesPage = Schema.decodeUnknownEffect( + Schema.Struct({ + timeSeries: Schema.optionalKey(Schema.Array(TimeSeries)), + nextPageToken: Schema.optionalKey(Schema.String), + }), +) + +export interface GcpTimeSeriesQuery { + /** `projects/`, `folders/` or `organizations/`. */ + readonly scope: string + readonly group: GcpMetricGroup + readonly metric: GcpMetric + readonly startMs: number + readonly endMs: number +} + +/** + * One page of one metric type over `[startMs, endMs]` for everything in the scope, aligned to + * 60 seconds and reduced to the project plus the labels the table keeps. + */ +export const listTimeSeriesPage = ( + httpClient: HttpClient.HttpClient, + reader: GcpReader, + query: GcpTimeSeriesQuery, + pageToken: string | undefined, +) => + call( + httpClient, + "Cloud Monitoring", + HttpClientRequest.get(`https://monitoring.googleapis.com/v3/${query.scope}/timeSeries`).pipe( + asReader(reader), + HttpClientRequest.setUrlParams({ + filter: `metric.type = "${query.metric.type}" AND resource.type = "${query.group.resourceType}"`, + "interval.startTime": new Date(query.startMs).toISOString(), + "interval.endTime": new Date(query.endMs).toISOString(), + "aggregation.alignmentPeriod": "60s", + "aggregation.perSeriesAligner": query.metric.aligner, + "aggregation.crossSeriesReducer": query.metric.reducer, + "aggregation.groupByFields": [ + "resource.label.project_id", + ...query.group.resourceLabels.map((label) => `resource.label.${label}`), + ...query.metric.labels.map((label) => `metric.label.${label}`), + ], + view: "FULL", + pageSize: TIME_SERIES_PAGE_SIZE, + pageToken, + }), + ), + decodeTimeSeriesPage, + ) + +// Cloud Asset Inventory + +const ResourceSearchResult = Schema.Struct({ + /** Full resource name, e.g. `//run.googleapis.com/projects/p/locations/l/services/s`. */ + name: Schema.String, + assetType: Schema.String, + displayName: Schema.optionalKey(Schema.String), + location: Schema.optionalKey(Schema.String), + state: Schema.optionalKey(Schema.String), + labels: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + createTime: Schema.optionalKey(Schema.String), + updateTime: Schema.optionalKey(Schema.String), + /** For a project it carries `projectId`. */ + additionalAttributes: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), +}) +export type GcpResourceSearchResult = typeof ResourceSearchResult.Type + +const decodeResourcePage = Schema.decodeUnknownEffect( + Schema.Struct({ + results: Schema.optionalKey(Schema.Array(ResourceSearchResult)), + nextPageToken: Schema.optionalKey(Schema.String), + }), +) + +/** One page of the curated asset types in the scope. */ +export const searchResourcesPage = ( + httpClient: HttpClient.HttpClient, + reader: GcpReader, + scope: string, + pageToken: string | undefined, +) => + call( + httpClient, + "Cloud Asset Inventory", + HttpClientRequest.get(`https://cloudasset.googleapis.com/v1/${scope}:searchAllResources`).pipe( + asReader(reader), + HttpClientRequest.setUrlParams({ + assetTypes: GCP_ASSET_TYPES, + readMask: Object.keys(ResourceSearchResult.fields).join(","), + pageSize: RESOURCE_PAGE_SIZE, + pageToken, + }), + ), + decodeResourcePage, + ) diff --git a/packages/backend/src/services/integrations/gcp/mapping.test.ts b/packages/backend/src/services/integrations/gcp/mapping.test.ts new file mode 100644 index 0000000000..9a990da86d --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/mapping.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest" +import { GCP_METRIC_GROUPS, type GcpMetric, type GcpMetricGroup } from "@maple/domain/gcp-metrics" +import type { GcpTimeSeries } from "./api" +import { + distributionQuantile, + GCP_SCOPE_NAME, + gcpResourceAttributes, + mapGcpResource, + mapGcpTimeSeries, +} from "./mapping" + +const startMs = Date.UTC(2026, 9, 8, 12, 0, 0) +const endMs = startMs + 5 * 60_000 +const at = (minutes: number) => new Date(startMs + minutes * 60_000).toISOString() + +const find = (type: string): { readonly group: GcpMetricGroup; readonly metric: GcpMetric } => { + for (const group of GCP_METRIC_GROUPS) { + const metric = group.metrics.find((candidate) => candidate.type === type) + if (metric !== undefined) return { group, metric } + } + throw new Error(`${type} is not a curated metric`) +} + +const map = (type: string, series: ReadonlyArray) => + mapGcpTimeSeries({ ...find(type), startMs, endMs }, series) + +const cloudRun = { + labels: { project_id: "acme-prod", service_name: "checkout", location: "europe-west4" }, +} + +describe("mapGcpTimeSeries", () => { + it("stores a counter as one delta sum per minute, with only the allowlisted labels", () => { + const { sumRows, gaugeRows } = map("run.googleapis.com/request_count", [ + { + metric: { labels: { response_code_class: "5xx", response_code: "503" } }, + resource: cloudRun, + points: [ + { interval: { endTime: at(2) }, value: { int64Value: "7" } }, + { interval: { endTime: at(1) }, value: { int64Value: "0" } }, + ], + }, + ]) + expect(gaugeRows).toEqual([]) + // The idle minute is not stored. + expect(sumRows).toHaveLength(1) + expect(sumRows[0]).toMatchObject({ + metric_name: "gcp.run.request_count", + metric_unit: "{request}", + metric_attributes: { response_code_class: "5xx" }, + service_name: "checkout", + scope_name: GCP_SCOPE_NAME, + value: 7, + aggregation_temporality: 1, + is_monotonic: true, + start_timestamp: "2026-10-08 12:01:00.000", + timestamp: "2026-10-08 12:02:00.000", + }) + }) + + it("stores a gauge as sampled, zero included", () => { + const { sumRows, gaugeRows } = map("run.googleapis.com/container/instance_count", [ + { + metric: { labels: { state: "idle" } }, + resource: cloudRun, + points: [ + { interval: { endTime: at(1) }, value: { doubleValue: 0 } }, + { interval: { endTime: at(2) }, value: { doubleValue: 2.5 } }, + ], + }, + ]) + expect(sumRows).toEqual([]) + expect(gaugeRows.map((row) => [row.timestamp, row.start_timestamp, row.value])).toEqual([ + ["2026-10-08 12:01:00.000", "2026-10-08 12:01:00.000", 0], + ["2026-10-08 12:02:00.000", "2026-10-08 12:02:00.000", 2.5], + ]) + expect(gaugeRows[0]!.metric_attributes).toEqual({ state: "idle" }) + }) + + it("keeps the points that end inside (start, end]", () => { + const { gaugeRows } = map("cloudsql.googleapis.com/database/cpu/utilization", [ + { + metric: {}, + resource: { labels: { database_id: "acme-prod:main", region: "us-central1" } }, + points: [0, 1, 5, 6].map((minute) => ({ + interval: { endTime: at(minute) }, + value: { doubleValue: minute }, + })), + }, + ]) + expect(gaugeRows.map((row) => row.value)).toEqual([1, 5]) + }) + + it("turns a distribution into p50/p95/p99 gauges, converting the unit", () => { + const { gaugeRows } = map("cloudfunctions.googleapis.com/function/execution_times", [ + { + metric: { labels: { status: "ok" } }, + resource: { labels: { function_name: "resize", region: "us-central1" } }, + points: [ + { + interval: { endTime: at(1) }, + value: { + distributionValue: { + // Finite buckets [0, 100ms), [100ms, 200ms), in nanoseconds. + bucketOptions: { + linearBuckets: { numFiniteBuckets: 2, width: 100_000_000 }, + }, + bucketCounts: ["0", "50", "50"], + }, + }, + }, + // No executions in this minute. + { interval: { endTime: at(2) }, value: { distributionValue: {} } }, + ], + }, + ]) + expect(gaugeRows.map((row) => [row.metric_attributes, Math.round(row.value)])).toEqual([ + [{ quantile: "0.5" }, 100], + [{ quantile: "0.95" }, 190], + [{ quantile: "0.99" }, 198], + ]) + expect(gaugeRows[0]).toMatchObject({ + metric_name: "gcp.cloudfunctions.function.execution_times", + metric_unit: "ms", + service_name: "resize", + }) + }) +}) + +describe("distributionQuantile", () => { + it("reads exponential and explicit buckets, closing the open-ended ones at their bound", () => { + // Bucket i covers [scale * growth^(i-1), scale * growth^i): 1-2, 2-4, 4-8. + const exponential = { + bucketOptions: { exponentialBuckets: { numFiniteBuckets: 3, growthFactor: 2, scale: 1 } }, + bucketCounts: ["0", "0", "10", "0", "10"], + } + expect(distributionQuantile(exponential, 0.25)).toBe(3) + // The upper half sits in the overflow bucket, which starts at 8. + expect(distributionQuantile(exponential, 0.99)).toBe(8) + + const explicit = { + bucketOptions: { explicitBuckets: { bounds: [10, 20] } }, + bucketCounts: ["4", "4"], + } + // Underflow bucket: everything below 10. + expect(distributionQuantile(explicit, 0.5)).toBe(10) + expect(distributionQuantile(explicit, 0.75)).toBe(15) + expect(distributionQuantile({}, 0.5)).toBeUndefined() + }) +}) + +describe("gcpResourceAttributes", () => { + it("names compute workloads after the workload, as the log receiver does", () => { + // The project comes from the series, so a folder or organization scope attributes each one. + expect(gcpResourceAttributes("cloud_run_revision", cloudRun.labels, {})).toEqual({ + "cloud.provider": "gcp", + "cloud.account.id": "acme-prod", + "cloud.platform": "gcp_cloud_run", + "cloud.region": "europe-west4", + "gcp.resource.type": "cloud_run_revision", + "faas.name": "checkout", + "service.name": "checkout", + "gcp.resource.labels.project_id": "acme-prod", + "gcp.resource.labels.service_name": "checkout", + "gcp.resource.labels.location": "europe-west4", + }) + + const container = gcpResourceAttributes( + "k8s_container", + { + cluster_name: "prod", + location: "us-central1-a", + namespace_name: "payments", + container_name: "api", + }, + {}, + ) + expect(container).toMatchObject({ + "service.name": "api", + "cloud.platform": "gcp_kubernetes_engine", + "cloud.region": "us-central1", + "cloud.availability_zone": "us-central1-a", + "k8s.cluster.name": "prod", + "k8s.namespace.name": "payments", + "k8s.container.name": "api", + }) + + // The instance name is a metric label on Compute Engine metrics. + const instance = gcpResourceAttributes( + "gce_instance", + { instance_id: "8123456789", zone: "europe-west1-b" }, + { instance_name: "web-1" }, + ) + expect(instance).toMatchObject({ + "service.name": "web-1", + "host.name": "web-1", + "host.id": "8123456789", + "cloud.platform": "gcp_compute_engine", + "cloud.availability_zone": "europe-west1-b", + }) + }) + + it("names managed services gcp/", () => { + const database = gcpResourceAttributes( + "cloudsql_database", + { database_id: "acme-prod:main", region: "us-central1" }, + {}, + ) + expect(database["service.name"]).toBe("gcp/cloudsql_database") + expect(database["cloud.region"]).toBe("us-central1") + expect(database).not.toHaveProperty("cloud.platform") + + const nodes = gcpResourceAttributes("k8s_node", { cluster_name: "prod" }, {}) + expect(nodes).toMatchObject({ "service.name": "gcp/k8s_node", "k8s.cluster.name": "prod" }) + expect(gcpResourceAttributes("pubsub_topic", { topic_id: "orders" }, {})["service.name"]).toBe( + "gcp/pubsub_topic", + ) + }) +}) + +describe("mapGcpResource", () => { + it("reads the project off the resource name, and a project's id off its attribute", () => { + expect( + mapGcpResource({ + name: "//run.googleapis.com/projects/acme-prod/locations/europe-west4/services/checkout", + assetType: "run.googleapis.com/Service", + displayName: "checkout", + location: "europe-west4", + labels: { team: "payments" }, + createTime: "2026-01-02T03:04:05Z", + updateTime: "not a time", + }), + ).toEqual({ + name: "//run.googleapis.com/projects/acme-prod/locations/europe-west4/services/checkout", + assetType: "run.googleapis.com/Service", + projectId: "acme-prod", + location: "europe-west4", + displayName: "checkout", + state: null, + labels: { team: "payments" }, + createdAtMs: Date.UTC(2026, 0, 2, 3, 4, 5), + updatedAtMs: null, + }) + + const project = mapGcpResource({ + name: "//cloudresourcemanager.googleapis.com/projects/123456789", + assetType: "cloudresourcemanager.googleapis.com/Project", + displayName: "Acme production", + state: "ACTIVE", + additionalAttributes: { projectId: "acme-prod" }, + }) + expect(project).toMatchObject({ projectId: "acme-prod", state: "ACTIVE", labels: {} }) + + expect( + mapGcpResource({ + name: "//cloudresourcemanager.googleapis.com/folders/42", + assetType: "cloudresourcemanager.googleapis.com/Folder", + }), + ).toBeUndefined() + }) +}) diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts new file mode 100644 index 0000000000..b6910a51b9 --- /dev/null +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -0,0 +1,220 @@ +/** + * Cloud Monitoring time series -> OTel metric rows, and Cloud Asset Inventory search results -> + * inventory entries. + * + * Resource attributes follow the log receiver (`apps/ingest/src/gcp_logging.rs`), so a workload's + * metrics and logs land on the same service: compute workloads are named after the workload, + * everything else is `gcp/`. + */ +import type { GcpMetric, GcpMetricGroup } from "@maple/domain/gcp-metrics" +import { + fmtMetricTs, + type MetricAttrs, + type MetricGaugeRow, + type MetricSumRow, +} from "@maple/backend/services/warehouse/metric-rows" +import { msToDate, timestampMs } from "@maple/backend/platform/time" +import { Predicate } from "effect" +import type { GcpBucketOptions, GcpResourceSearchResult, GcpTimeSeries } from "./api" + +export const GCP_SCOPE_NAME = "gcp.monitoring" + +const ALIGNMENT_MS = 60_000 +const QUANTILES = [0.5, 0.95, 0.99] +const DELTA_TEMPORALITY = 1 + +export interface GcpMetricRows { + readonly sumRows: Array + readonly gaugeRows: Array +} + +const cloudPlatform = (resourceType: string): string | undefined => { + if (resourceType === "cloud_run_revision") return "gcp_cloud_run" + if (resourceType === "cloud_function") return "gcp_cloud_functions" + if (resourceType === "gce_instance") return "gcp_compute_engine" + return resourceType.startsWith("k8s_") ? "gcp_kubernetes_engine" : undefined +} + +/** `labels` are the series' resource labels: the project plus the group's `resourceLabels`. */ +export const gcpResourceAttributes = ( + resourceType: string, + labels: MetricAttrs, + metricLabels: MetricAttrs, +): MetricAttrs => { + const attributes: MetricAttrs = { "cloud.provider": "gcp", "gcp.resource.type": resourceType } + const set = (key: string, value: string | undefined) => { + if (value) attributes[key] = value + } + set("cloud.account.id", labels.project_id) + set("cloud.platform", cloudPlatform(resourceType)) + + // `location` names a zone for zonal resources and a region otherwise. + const location = labels.zone ?? labels.location ?? labels.region + const zone = location?.match(/^(.+)-[a-z]$/) + set("cloud.region", zone ? zone[1] : location) + if (zone) set("cloud.availability_zone", location) + + let workload: string | undefined + if (resourceType === "cloud_run_revision") { + workload = labels.service_name + set("faas.name", workload) + } else if (resourceType === "cloud_function") { + workload = labels.function_name + set("faas.name", workload) + } else if (resourceType === "k8s_container") { + workload = labels.container_name + set("k8s.cluster.name", labels.cluster_name) + set("k8s.namespace.name", labels.namespace_name) + set("k8s.container.name", workload) + } else if (resourceType === "k8s_node") { + set("k8s.cluster.name", labels.cluster_name) + } else if (resourceType === "gce_instance") { + workload = metricLabels.instance_name + set("host.id", labels.instance_id) + set("host.name", workload) + } + attributes["service.name"] = workload || `gcp/${resourceType}` + + for (const [label, value] of Object.entries(labels)) attributes[`gcp.resource.labels.${label}`] = value + return attributes +} + +/** + * Bounds of bucket `index`. Bucket 0 is the underflow bucket and the last one the overflow + * bucket; both are open-ended, so they collapse to their one finite bound. + */ +const bucketBounds = (options: GcpBucketOptions, index: number): readonly [number, number] => { + const between = (bound: (edge: number) => number, lastEdge: number): readonly [number, number] => [ + bound(Math.max(index - 1, 0)), + bound(Math.min(index, lastEdge)), + ] + if (options.linearBuckets) { + const { numFiniteBuckets, width, offset = 0 } = options.linearBuckets + return between((edge) => offset + width * edge, numFiniteBuckets) + } + if (options.exponentialBuckets) { + const { numFiniteBuckets, growthFactor, scale } = options.exponentialBuckets + return between((edge) => scale * growthFactor ** edge, numFiniteBuckets) + } + const bounds = options.explicitBuckets?.bounds ?? [] + return between((edge) => bounds[edge] ?? 0, bounds.length - 1) +} + +type GcpDistribution = NonNullable[number]["value"]["distributionValue"]> + +/** Quantile `q` by linear interpolation inside its bucket, as Cloud Monitoring's own percentile reducers do. */ +export const distributionQuantile = (distribution: GcpDistribution, q: number): number | undefined => { + const counts = (distribution.bucketCounts ?? []).map(Number) + const rank = q * counts.reduce((sum, count) => sum + count, 0) + if (rank === 0 || distribution.bucketOptions === undefined) return undefined + let seen = 0 + for (const [index, count] of counts.entries()) { + if (count > 0 && seen + count >= rank) { + const [lower, upper] = bucketBounds(distribution.bucketOptions, index) + return lower + (upper - lower) * ((rank - seen) / count) + } + seen += count + } + return undefined +} + +const toDate = (value: string | undefined): Date | null => { + const ms = value === undefined ? Number.NaN : timestampMs(value) + return msToDate(Number.isNaN(ms) ? null : ms) +} + +/** The `gcp_resources` columns of a search result, or undefined when it names no project. */ +export const mapGcpResource = (result: GcpResourceSearchResult) => { + // A project is found by its `projectId` attribute; everything else carries the id in its name. + const attribute = result.additionalAttributes?.projectId + const projectId = Predicate.isString(attribute) ? attribute : /\/projects\/([^/]+)/.exec(result.name)?.[1] + if (projectId === undefined) return undefined + return { + name: result.name, + assetType: result.assetType, + projectId, + location: result.location ?? null, + displayName: result.displayName ?? null, + state: result.state ?? null, + labels: result.labels ?? {}, + resourceCreatedAt: toDate(result.createTime), + resourceUpdatedAt: toDate(result.updateTime), + } +} + +/** + * Rows for one metric's series. A point is kept when it ends inside `(startMs, endMs]`: the query + * interval is closed, so the point ending at `startMs` was already taken by the previous window. + */ +export const mapGcpTimeSeries = ( + query: { + readonly group: GcpMetricGroup + readonly metric: GcpMetric + readonly startMs: number + readonly endMs: number + }, + series: ReadonlyArray, +): GcpMetricRows => { + const { group, metric } = query + const rows: GcpMetricRows = { sumRows: [], gaugeRows: [] } + for (const item of series) { + const metricLabels = item.metric.labels ?? {} + const resourceAttributes = gcpResourceAttributes( + group.resourceType, + item.resource.labels ?? {}, + metricLabels, + ) + const attributes = Object.fromEntries( + metric.labels.flatMap((label) => { + const value = metricLabels[label] + return value === undefined ? [] : [[label, value]] + }), + ) + const row = (endMs: number, value: number, metricAttributes: MetricAttrs): MetricGaugeRow => ({ + timestamp: fmtMetricTs(endMs), + start_timestamp: fmtMetricTs(metric.kind === "sum" ? endMs - ALIGNMENT_MS : endMs), + metric_name: metric.name, + metric_description: metric.description, + metric_unit: metric.unit, + metric_attributes: metricAttributes, + service_name: resourceAttributes["service.name"] ?? "", + resource_schema_url: "", + resource_attributes: resourceAttributes, + scope_schema_url: "", + scope_name: GCP_SCOPE_NAME, + scope_version: "", + scope_attributes: {}, + value: value * metric.scale, + flags: 0, + exemplars_trace_id: [], + exemplars_span_id: [], + exemplars_timestamp: [], + exemplars_value: [], + exemplars_filtered_attributes: [], + }) + for (const point of item.points ?? []) { + const endMs = Date.parse(point.interval.endTime) + if (!(endMs > query.startMs && endMs <= query.endMs)) continue + if (metric.kind === "quantiles") { + for (const q of QUANTILES) { + const value = distributionQuantile(point.value.distributionValue ?? {}, q) + if (value !== undefined) rows.gaugeRows.push(row(endMs, value, { quantile: String(q) })) + } + continue + } + const value = point.value.doubleValue ?? Number(point.value.int64Value ?? 0) + if (!Number.isFinite(value)) continue + if (metric.kind === "gauge") { + rows.gaugeRows.push(row(endMs, value, attributes)) + } else if (value > 0) { + // An idle minute is a zero delta: nothing to store. + rows.sumRows.push({ + ...row(endMs, value, attributes), + aggregation_temporality: DELTA_TEMPORALITY, + is_monotonic: true, + }) + } + } + } + return rows +} diff --git a/packages/backend/src/services/org/OrganizationService.ts b/packages/backend/src/services/org/OrganizationService.ts index f9f26f4db8..5a9a197b6f 100644 --- a/packages/backend/src/services/org/OrganizationService.ts +++ b/packages/backend/src/services/org/OrganizationService.ts @@ -33,6 +33,7 @@ import { cliDeviceAuthorizations, cloudflareLogpushConnectors, gcpConnectors, + gcpResources, dashboards, dashboardShares, dashboardVersions, @@ -116,6 +117,8 @@ const ORG_SCOPED_TABLES = [ cloudflareLogpushConnectors, // A surviving push secret would keep the ingest gateway accepting logs for a deleted org. gcpConnectors, + // Also removed with its connector, by the foreign key. + gcpResources, errorIssueEvents, errorIssueStates, errorIncidents, diff --git a/packages/db/src/schema/gcp-connectors.ts b/packages/db/src/schema/gcp-connectors.ts index 35cc69730f..7ff3a2458f 100644 --- a/packages/db/src/schema/gcp-connectors.ts +++ b/packages/db/src/schema/gcp-connectors.ts @@ -15,6 +15,16 @@ export const gcpConnectors = pgTable( secretHash: text("secret_hash").notNull(), lastReceivedAt: timestamp("last_received_at", { withTimezone: true, mode: "date" }), lastError: text("last_error"), + // Metrics poller: end of the last minute it ingested. Null until the first successful poll. + metricsWatermarkAt: timestamp("metrics_watermark_at", { withTimezone: true, mode: "date" }), + lastMetricsReceivedAt: timestamp("last_metrics_received_at", { withTimezone: true, mode: "date" }), + lastMetricsError: text("last_metrics_error"), + // A tick claims the connector by moving this past now, and it is never cleared: it also + // orders the next tick, least recently polled first. + metricsLeaseUntil: timestamp("metrics_lease_until", { withTimezone: true, mode: "date" }), + // Last complete sync of `gcp_resources`, and why the latest attempt fell short. + resourcesSyncedAt: timestamp("resources_synced_at", { withTimezone: true, mode: "date" }), + lastResourcesError: text("last_resources_error"), createdBy: text("created_by").notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).notNull(), diff --git a/packages/db/src/schema/gcp-resources.ts b/packages/db/src/schema/gcp-resources.ts new file mode 100644 index 0000000000..982be70297 --- /dev/null +++ b/packages/db/src/schema/gcp-resources.ts @@ -0,0 +1,33 @@ +import type { GcpConnectorId, OrgId } from "@maple/domain" +import { jsonb, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core" +import { gcpConnectors } from "./gcp-connectors" + +/** + * What a Google Cloud connector's scope contains, synced from Cloud Asset Inventory: its + * projects (that is how a folder or organization scope is discovered) and the resources behind + * the curated metrics. A sync replaces the connector's set, so a resource that is gone upstream + * is gone here; `lastSeenAt` is the sync that last returned the row. + */ +export const gcpResources = pgTable( + "gcp_resources", + { + connectorId: text("connector_id") + .$type() + .notNull() + .references(() => gcpConnectors.id, { onDelete: "cascade" }), + orgId: text("org_id").$type().notNull(), + /** Full resource name, e.g. `//run.googleapis.com/projects/p/locations/l/services/s`. */ + name: text("name").notNull(), + /** Cloud Asset Inventory type, e.g. `run.googleapis.com/Service`. */ + assetType: text("asset_type").notNull(), + projectId: text("project_id").notNull(), + location: text("location"), + displayName: text("display_name"), + state: text("state"), + labels: jsonb("labels").$type>().notNull(), + resourceCreatedAt: timestamp("resource_created_at", { withTimezone: true, mode: "date" }), + resourceUpdatedAt: timestamp("resource_updated_at", { withTimezone: true, mode: "date" }), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true, mode: "date" }).notNull(), + }, + (table) => [primaryKey({ columns: [table.connectorId, table.name] })], +) diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 5733038591..111cc2efa2 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -10,6 +10,7 @@ export * from "./cloudflare-analytics-state" export * from "./cloudflare-hyperdrive-configs" export * from "./cloudflare-logpush-connectors" export * from "./gcp-connectors" +export * from "./gcp-resources" export * from "./cli-device-authorizations" export * from "./mcp-oauth" export * from "./mobile-devices" diff --git a/packages/domain/package.json b/packages/domain/package.json index 927cf6cb70..50de7d0547 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -12,6 +12,7 @@ "./billing-tax-ids": "./src/billing-tax-ids.ts", "./format": "./src/format.ts", "./gcp": "./src/gcp.ts", + "./gcp-metrics": "./src/gcp-metrics.ts", "./gen-ai": "./src/gen-ai.ts", "./glob": "./src/glob.ts", "./http": "./src/http/index.ts", diff --git a/packages/domain/src/gcp-metrics.test.ts b/packages/domain/src/gcp-metrics.test.ts new file mode 100644 index 0000000000..9cb6bc2ed0 --- /dev/null +++ b/packages/domain/src/gcp-metrics.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest" +import { GCP_ASSET_TYPES, GCP_METRIC_GROUPS, GCP_PROJECT_ASSET_TYPE } from "./gcp-metrics" + +describe("GCP_METRIC_GROUPS", () => { + const metrics = GCP_METRIC_GROUPS.flatMap((group) => group.metrics) + + it("names every metric after its Cloud Monitoring type, under gcp.", () => { + const names = Object.fromEntries(metrics.map((metric) => [metric.type, metric.name])) + expect(names).toMatchObject({ + "run.googleapis.com/request_count": "gcp.run.request_count", + "kubernetes.io/container/cpu/core_usage_time": "gcp.kubernetes.container.cpu.core_usage_time", + "cloudsql.googleapis.com/database/postgresql/num_backends": + "gcp.cloudsql.database.postgresql.num_backends", + }) + expect(new Set(Object.values(names)).size).toBe(metrics.length) + expect(new Set(Object.keys(names)).size).toBe(metrics.length) + }) + + it("aligns counters and distributions as deltas, gauges as means", () => { + for (const metric of metrics) { + expect(metric.aligner, metric.type).toBe(metric.kind === "gauge" ? "ALIGN_MEAN" : "ALIGN_DELTA") + if (metric.kind !== "gauge") expect(metric.reducer, metric.type).toBe("REDUCE_SUM") + } + }) + + it("never groups by the project itself: every query adds it", () => { + for (const group of GCP_METRIC_GROUPS) expect(group.resourceLabels).not.toContain("project_id") + }) +}) + +describe("GCP_ASSET_TYPES", () => { + it("lists projects, which is how a folder or organization scope is discovered", () => { + expect(GCP_ASSET_TYPES).toContain(GCP_PROJECT_ASSET_TYPE) + expect(new Set(GCP_ASSET_TYPES).size).toBe(GCP_ASSET_TYPES.length) + }) +}) diff --git a/packages/domain/src/gcp-metrics.ts b/packages/domain/src/gcp-metrics.ts new file mode 100644 index 0000000000..6f148ea1c3 --- /dev/null +++ b/packages/domain/src/gcp-metrics.ts @@ -0,0 +1,409 @@ +/** + * What Maple collects from a connected Google Cloud project, folder or organization. The metric + * table is the whole metrics scope: the poller issues one `timeSeries.list` query per entry, and + * anything that charts Google Cloud metrics reads the Maple names from here. + * + * Cloud Monitoring aligns every series to 60 seconds and reduces it to the project, the group's + * `resourceLabels` and the metric's `labels`; every other label (revision, pod, response code, + * device) is aggregated away before it reaches Maple, which is what bounds cardinality. + */ + +export interface GcpMetric { + /** Cloud Monitoring metric type. */ + readonly type: string + /** Metric name in Maple: the type under a `gcp.` prefix, e.g. `gcp.run.request_count`. */ + readonly name: string + readonly unit: string + readonly description: string + /** + * What Maple stores: `sum` is a delta counter per minute, `gauge` a sampled value, and + * `quantiles` are p50/p95/p99 gauges (attribute `quantile`) computed from a distribution. + */ + readonly kind: "sum" | "gauge" | "quantiles" + readonly aligner: "ALIGN_DELTA" | "ALIGN_MEAN" + readonly reducer: "REDUCE_SUM" | "REDUCE_MEAN" + /** Metric labels kept as attributes under their Cloud Monitoring names. */ + readonly labels: ReadonlyArray + /** Multiplier from the Cloud Monitoring unit to `unit`. */ + readonly scale: number +} + +export interface GcpMetricGroup { + /** Monitored resource type the series are read from. */ + readonly resourceType: string + /** Resource labels each series keeps; they become the resource attributes. */ + readonly resourceLabels: ReadonlyArray + readonly metrics: ReadonlyArray +} + +/** `run.googleapis.com/request_count` -> `gcp.run.request_count`. */ +const mapleName = (type: string): string => { + const [domain = "", ...path] = type.split("/") + return ["gcp", domain.split(".")[0], ...path].join(".") +} + +const counter = ( + type: string, + unit: string, + description: string, + labels: ReadonlyArray = [], +): GcpMetric => ({ + type, + name: mapleName(type), + unit, + description, + kind: "sum", + aligner: "ALIGN_DELTA", + reducer: "REDUCE_SUM", + labels, + scale: 1, +}) + +const gauge = ( + type: string, + unit: string, + description: string, + reducer: GcpMetric["reducer"], + labels: ReadonlyArray = [], +): GcpMetric => ({ + type, + name: mapleName(type), + unit, + description, + kind: "gauge", + aligner: "ALIGN_MEAN", + reducer, + labels, + scale: 1, +}) + +/** A distribution, summed across the group so the percentiles cover every instance in it. */ +const quantiles = (type: string, unit: string, description: string, scale = 1): GcpMetric => ({ + type, + name: mapleName(type), + unit, + description, + kind: "quantiles", + aligner: "ALIGN_DELTA", + reducer: "REDUCE_SUM", + labels: [], + scale, +}) + +/** + * Cloud Asset Inventory types kept in the resource inventory: the projects in a connector's + * scope, and the resources the metrics below describe. + */ +export const GCP_PROJECT_ASSET_TYPE = "cloudresourcemanager.googleapis.com/Project" +export const GCP_ASSET_TYPES: ReadonlyArray = [ + GCP_PROJECT_ASSET_TYPE, + "run.googleapis.com/Service", + "run.googleapis.com/Job", + // 1st and 2nd gen. + "cloudfunctions.googleapis.com/CloudFunction", + "cloudfunctions.googleapis.com/Function", + "container.googleapis.com/Cluster", + "compute.googleapis.com/Instance", + "sqladmin.googleapis.com/Instance", + "pubsub.googleapis.com/Topic", + "pubsub.googleapis.com/Subscription", + // Load balancing: both cover global and regional resources. + "compute.googleapis.com/UrlMap", + "compute.googleapis.com/BackendService", + "compute.googleapis.com/ForwardingRule", +] + +export const GCP_METRIC_GROUPS: ReadonlyArray = [ + { + // Cloud Run services, and Cloud Functions (2nd gen), which run on Cloud Run. + resourceType: "cloud_run_revision", + resourceLabels: ["service_name", "location"], + metrics: [ + counter("run.googleapis.com/request_count", "{request}", "Requests that reached the service", [ + "response_code_class", + ]), + quantiles("run.googleapis.com/request_latencies", "ms", "Request latency"), + gauge( + "run.googleapis.com/container/instance_count", + "{instance}", + "Container instances", + "REDUCE_SUM", + ["state"], + ), + quantiles( + "run.googleapis.com/container/cpu/utilizations", + "1", + "CPU utilization across container instances", + ), + quantiles( + "run.googleapis.com/container/memory/utilizations", + "1", + "Memory utilization across container instances", + ), + quantiles( + "run.googleapis.com/container/max_request_concurrencies", + "{request}", + "Peak concurrent requests per container instance", + ), + counter( + "run.googleapis.com/container/billable_instance_time", + "s", + "Billable container instance time", + ), + ], + }, + { + // Cloud Functions (1st gen). + resourceType: "cloud_function", + resourceLabels: ["function_name", "region"], + metrics: [ + counter( + "cloudfunctions.googleapis.com/function/execution_count", + "{execution}", + "Function executions", + ["status"], + ), + // Reported in nanoseconds. + quantiles( + "cloudfunctions.googleapis.com/function/execution_times", + "ms", + "Function execution time", + 1e-6, + ), + gauge( + "cloudfunctions.googleapis.com/function/instance_count", + "{instance}", + "Function instances", + "REDUCE_SUM", + ["state"], + ), + quantiles( + "cloudfunctions.googleapis.com/function/user_memory_bytes", + "By", + "Peak memory used per execution", + ), + counter( + "cloudfunctions.googleapis.com/function/network_egress", + "By", + "Outgoing network traffic", + ), + ], + }, + { + // GKE workloads, one series per container name: pods are aggregated away. + resourceType: "k8s_container", + resourceLabels: ["cluster_name", "location", "namespace_name", "container_name"], + metrics: [ + counter("kubernetes.io/container/cpu/core_usage_time", "s", "CPU time used"), + gauge( + "kubernetes.io/container/cpu/limit_utilization", + "1", + "Fraction of the CPU limit in use, averaged over pods", + "REDUCE_MEAN", + ), + gauge("kubernetes.io/container/memory/used_bytes", "By", "Memory in use", "REDUCE_SUM", [ + "memory_type", + ]), + gauge( + "kubernetes.io/container/memory/limit_utilization", + "1", + "Fraction of the memory limit in use, averaged over pods", + "REDUCE_MEAN", + ["memory_type"], + ), + counter("kubernetes.io/container/restart_count", "{restart}", "Container restarts"), + ], + }, + { + // GKE nodes, averaged per cluster: node names churn with autoscaling. + resourceType: "k8s_node", + resourceLabels: ["cluster_name", "location"], + metrics: [ + gauge( + "kubernetes.io/node/cpu/allocatable_utilization", + "1", + "Fraction of allocatable CPU in use, averaged over nodes", + "REDUCE_MEAN", + ), + gauge( + "kubernetes.io/node/memory/allocatable_utilization", + "1", + "Fraction of allocatable memory in use, averaged over nodes", + "REDUCE_MEAN", + ["memory_type"], + ), + ], + }, + { + // Compute Engine VMs. `instance_name` is a metric label; it names the service. + resourceType: "gce_instance", + resourceLabels: ["instance_id", "zone"], + metrics: [ + gauge( + "compute.googleapis.com/instance/cpu/utilization", + "1", + "Fraction of the allocated CPU in use", + "REDUCE_MEAN", + ["instance_name"], + ), + // E2 machine types only. + gauge( + "compute.googleapis.com/instance/memory/balloon/ram_used", + "By", + "Memory in use", + "REDUCE_SUM", + ["instance_name"], + ), + counter("compute.googleapis.com/instance/network/received_bytes_count", "By", "Bytes received", [ + "instance_name", + ]), + counter("compute.googleapis.com/instance/network/sent_bytes_count", "By", "Bytes sent", [ + "instance_name", + ]), + counter("compute.googleapis.com/instance/disk/read_bytes_count", "By", "Bytes read from disk", [ + "instance_name", + ]), + counter("compute.googleapis.com/instance/disk/write_bytes_count", "By", "Bytes written to disk", [ + "instance_name", + ]), + ], + }, + { + resourceType: "cloudsql_database", + resourceLabels: ["database_id", "region"], + metrics: [ + gauge( + "cloudsql.googleapis.com/database/cpu/utilization", + "1", + "Fraction of the reserved CPU in use", + "REDUCE_MEAN", + ), + gauge( + "cloudsql.googleapis.com/database/memory/utilization", + "1", + "Fraction of the memory quota in use", + "REDUCE_MEAN", + ), + gauge( + "cloudsql.googleapis.com/database/disk/utilization", + "1", + "Fraction of the disk quota in use", + "REDUCE_MEAN", + ), + // MySQL and SQL Server. + gauge( + "cloudsql.googleapis.com/database/network/connections", + "{connection}", + "Connections to the instance", + "REDUCE_SUM", + ), + // PostgreSQL, summed over databases. + gauge( + "cloudsql.googleapis.com/database/postgresql/num_backends", + "{connection}", + "Connections to the instance", + "REDUCE_SUM", + ), + counter( + "cloudsql.googleapis.com/database/disk/read_ops_count", + "{operation}", + "Disk read operations", + ), + counter( + "cloudsql.googleapis.com/database/disk/write_ops_count", + "{operation}", + "Disk write operations", + ), + gauge( + "cloudsql.googleapis.com/database/replication/replica_lag", + "s", + "How far a read replica is behind its primary", + "REDUCE_MEAN", + ), + ], + }, + { + resourceType: "pubsub_subscription", + resourceLabels: ["subscription_id"], + metrics: [ + gauge( + "pubsub.googleapis.com/subscription/num_undelivered_messages", + "{message}", + "Unacknowledged messages", + "REDUCE_SUM", + ), + gauge( + "pubsub.googleapis.com/subscription/oldest_unacked_message_age", + "s", + "Age of the oldest unacknowledged message", + "REDUCE_MEAN", + ), + counter( + "pubsub.googleapis.com/subscription/sent_message_count", + "{message}", + "Messages sent to subscribers", + ), + counter( + "pubsub.googleapis.com/subscription/ack_message_count", + "{message}", + "Messages acknowledged", + ), + counter( + "pubsub.googleapis.com/subscription/push_request_count", + "{request}", + "Push delivery attempts", + ["response_class"], + ), + counter( + "pubsub.googleapis.com/subscription/dead_letter_message_count", + "{message}", + "Messages moved to the dead-letter topic", + ), + ], + }, + { + resourceType: "pubsub_topic", + resourceLabels: ["topic_id"], + metrics: [ + counter("pubsub.googleapis.com/topic/send_request_count", "{request}", "Publish requests", [ + "response_class", + ]), + ], + }, + { + // Global external Application Load Balancers. + resourceType: "https_lb_rule", + resourceLabels: ["url_map_name", "backend_target_name"], + metrics: [ + counter("loadbalancing.googleapis.com/https/request_count", "{request}", "Requests served", [ + "response_code_class", + ]), + quantiles( + "loadbalancing.googleapis.com/https/total_latencies", + "ms", + "Latency from the request reaching the proxy to the last response byte", + ), + quantiles( + "loadbalancing.googleapis.com/https/backend_latencies", + "ms", + "Latency from the proxy sending the request to the backend to the last response byte", + ), + counter( + "loadbalancing.googleapis.com/https/backend_request_count", + "{request}", + "Requests sent to backends", + ["response_code_class"], + ), + counter( + "loadbalancing.googleapis.com/https/request_bytes_count", + "By", + "Request bytes received from clients", + ), + counter( + "loadbalancing.googleapis.com/https/response_bytes_count", + "By", + "Response bytes sent to clients", + ), + ], + }, +] From 4fbb5e2edc90f9d7fdb84ab7dfe2587d1242b10a Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 19:47:30 +0200 Subject: [PATCH 02/17] feat(integrations): expose Google Cloud poll state on the connector, generate the migration --- .../routes/v2/integrations-gcp.http.test.ts | 4 + .../src/routes/v2/integrations-gcp.http.ts | 4 + .../integrations/GcpConnectorService.test.ts | 85 + .../integrations/GcpConnectorService.ts | 58 +- .../integrations/GcpMetricsService.test.ts | 171 +- .../integrations/GcpMetricsService.ts | 26 +- .../services/integrations/gcp/mapping.test.ts | 4 +- .../migration.sql | 23 + .../snapshot.json | 18470 ++++++++++++++++ packages/domain/src/gcp-metrics.test.ts | 19 +- .../domain/src/http/v2/integrations-gcp.ts | 22 + 11 files changed, 18824 insertions(+), 62 deletions(-) create mode 100644 packages/db/drizzle/20261008174147_gcp_metrics_poller/migration.sql create mode 100644 packages/db/drizzle/20261008174147_gcp_metrics_poller/snapshot.json diff --git a/apps/api/src/routes/v2/integrations-gcp.http.test.ts b/apps/api/src/routes/v2/integrations-gcp.http.test.ts index 6b620097b0..1eb6ebf26d 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.test.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.test.ts @@ -155,6 +155,10 @@ describe("v2 gcp integration over HTTP", () => { created_at: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.*Z$/), last_log_received_at: null, last_log_error: null, + last_metrics_received_at: null, + last_metrics_error: null, + discovered_project_count: 0, + last_resources_error: null, }) // A project is its own host project, and forwards logs only unless asked otherwise. diff --git a/apps/api/src/routes/v2/integrations-gcp.http.ts b/apps/api/src/routes/v2/integrations-gcp.http.ts index b029ce1b5d..9bda1ce832 100644 --- a/apps/api/src/routes/v2/integrations-gcp.http.ts +++ b/apps/api/src/routes/v2/integrations-gcp.http.ts @@ -31,6 +31,10 @@ const toV2Connector = (connector: GcpConnector): V2GcpConnector => ({ created_at: isoTimestamp(connector.createdAt), last_log_received_at: isoTimestampOrNull(connector.lastLogReceivedAt), last_log_error: connector.lastLogError, + last_metrics_received_at: isoTimestampOrNull(connector.lastMetricsReceivedAt), + last_metrics_error: connector.lastMetricsError, + discovered_project_count: connector.discoveredProjectCount, + last_resources_error: connector.lastResourcesError, }) const auditMetadata = (connector: GcpConnector) => ({ diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index 9223f38e84..a7f59560d1 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -202,6 +202,91 @@ describe("GcpConnectorService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) + it.effect("reports what the poller recorded, and restarts it when metrics are switched", () => { + const testDb = createTestDb(trackedDbs) + return Effect.gen(function* () { + const gcp = yield* GcpConnectorService + const connector = yield* gcp.create(orgId, userId, organizationScope) + assert.deepInclude(connector, { + lastMetricsReceivedAt: null, + lastMetricsError: null, + discoveredProjectCount: 0, + lastResourcesError: null, + }) + const other = yield* gcp.create(orgId, userId, projectScope("acme-prod")) + // What the poller writes: its state on the connector, and the scope's inventory. + yield* Effect.promise(() => + executeSql( + testDb, + `UPDATE gcp_connectors + SET metrics_watermark_at = $1, last_metrics_received_at = $1, last_metrics_error = $2, + resources_synced_at = $1, last_resources_error = $3 + WHERE id = $4`, + [ + "2026-10-08T09:10:00.000Z", + "2 of 46 metric queries failed.", + "incomplete", + connector.id, + ], + ), + ) + for (const [name, assetType] of [ + [ + "//cloudresourcemanager.googleapis.com/projects/111", + "cloudresourcemanager.googleapis.com/Project", + ], + [ + "//cloudresourcemanager.googleapis.com/projects/222", + "cloudresourcemanager.googleapis.com/Project", + ], + [ + "//run.googleapis.com/projects/shop/locations/eu/services/api", + "run.googleapis.com/Service", + ], + ]) { + yield* Effect.promise(() => + executeSql( + testDb, + `INSERT INTO gcp_resources (connector_id, org_id, name, asset_type, project_id, labels, last_seen_at) + VALUES ($1, $2, $3, $4, 'shop', '{}', now())`, + [connector.id, orgId, name, assetType], + ), + ) + } + const polled = { + ...connector, + lastMetricsReceivedAt: Date.parse("2026-10-08T09:10:00.000Z"), + lastMetricsError: "2 of 46 metric queries failed.", + discoveredProjectCount: 2, + lastResourcesError: "incomplete", + } + // Both were created in the same test-clock instant, so their order is by id. + assert.sameDeepMembers([...(yield* gcp.status(orgId)).connectors], [polled, other]) + + // A change that leaves metrics alone keeps the poller's position. + assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, { logsEnabled: true }), polled) + + // Off and on again: no replay of the gap, no error from before, a fresh inventory sync. + const off = yield* gcp.update(orgId, connector.id, { metricsEnabled: false }) + assert.deepInclude(off, { lastMetricsError: null, lastResourcesError: null }) + const state = yield* Effect.promise(() => + queryFirstRow<{ metrics_watermark_at: unknown; resources_synced_at: unknown }>( + testDb, + "SELECT metrics_watermark_at, resources_synced_at FROM gcp_connectors WHERE id = $1", + [connector.id], + ), + ) + assert.deepStrictEqual(state, { metrics_watermark_at: null, resources_synced_at: null }) + + // The inventory goes with its connector. + yield* gcp.delete(orgId, connector.id) + const left = yield* Effect.promise(() => + queryFirstRow<{ count: number }>(testDb, "SELECT count(*)::int AS count FROM gcp_resources"), + ) + assert.strictEqual(left?.count, 0) + }).pipe(Effect.provide(makeLayer(testDb, MAPLE_ACCOUNT))) + }) + it.effect("refuses to render a secret that was moved onto another connector's row", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index 57bf395de4..b4ee29aa6f 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -1,5 +1,12 @@ import { randomBytes, randomUUID } from "node:crypto" -import { gcpConnectors, hashIngestKey, parseIngestKeyLookupHmacKey, type GcpConnectorRow } from "@maple/db" +import { + gcpConnectors, + gcpResources, + hashIngestKey, + parseIngestKeyLookupHmacKey, + type GcpConnectorRow, +} from "@maple/db" +import { GCP_PROJECT_ASSET_TYPE } from "@maple/domain/gcp-metrics" import { GcpMetricsUnavailableError, GcpScopeAlreadyConnectedError, @@ -15,7 +22,7 @@ import { type OrgId, type UserId, } from "@maple/domain/primitives" -import { and, asc, eq } from "drizzle-orm" +import { and, asc, eq, sql } from "drizzle-orm" import { Clock, Context, Effect, Layer, Option, Redacted } from "effect" import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -38,6 +45,12 @@ export interface GcpConnector { /** Last log push the ingest gateway accepted from this connector. Null until the first one. */ readonly lastLogReceivedAt: number | null readonly lastLogError: string | null + /** Last poll that read the scope's metrics. Null until the first one. */ + readonly lastMetricsReceivedAt: number | null + readonly lastMetricsError: string | null + /** Projects the last inventory sync found in the scope. */ + readonly discoveredProjectCount: number + readonly lastResourcesError: string | null } export type CreateGcpConnectorInput = Pick< @@ -99,7 +112,7 @@ const toPersistenceError = makePersistenceErrorMapper( // Binds the ciphertext to its row, so a stored secret cannot be moved onto another connector. const secretAad = (connectorId: string) => Buffer.from(`gcp_connectors:v1:${connectorId}`, "utf8") -const toConnector = (row: GcpConnectorRow): GcpConnector => ({ +const toConnector = (row: GcpConnectorRow, discoveredProjectCount = 0): GcpConnector => ({ id: row.id, scopeType: row.scopeType, scopeId: row.scopeId, @@ -109,6 +122,10 @@ const toConnector = (row: GcpConnectorRow): GcpConnector => ({ createdAt: dateToMs(row.createdAt), lastLogReceivedAt: dateToMs(row.lastReceivedAt), lastLogError: row.lastError, + lastMetricsReceivedAt: dateToMs(row.lastMetricsReceivedAt), + lastMetricsError: row.lastMetricsError, + discoveredProjectCount, + lastResourcesError: row.lastResourcesError, }) const notFound = () => new IntegrationsNotFoundError({ message: "No such Google Cloud connector." }) @@ -171,6 +188,21 @@ export class GcpConnectorService extends Context.Service + dbExecute((db) => + db + .select({ connectorId: gcpResources.connectorId, count: sql`count(*)::int` }) + .from(gcpResources) + .where( + and( + eq(gcpResources.orgId, orgId), + eq(gcpResources.assetType, GCP_PROJECT_ASSET_TYPE), + ), + ) + .groupBy(gcpResources.connectorId), + ).pipe(Effect.map((rows) => new Map(rows.map((row) => [row.connectorId, row.count])))) + const status = Effect.fn("GcpConnectorService.status")(function* (orgId: OrgId) { yield* Effect.annotateCurrentSpan({ orgId }) const rows = yield* dbExecute((db) => @@ -180,9 +212,10 @@ export class GcpConnectorService extends Context.Service toConnector(row, counts.get(row.id))), } }) @@ -255,12 +288,25 @@ export class GcpConnectorService extends Context.Service db .update(gcpConnectors) - .set({ ...flags, updatedAt }) + .set({ + ...flags, + // Switching metrics restarts the poller from a fresh window: it does not + // replay the time they were off, or keep an error from before. + ...(flags.metricsEnabled === current.metricsEnabled + ? undefined + : { + metricsWatermarkAt: null, + lastMetricsError: null, + resourcesSyncedAt: null, + lastResourcesError: null, + }), + updatedAt, + }) .where(and(eq(gcpConnectors.orgId, orgId), eq(gcpConnectors.id, connectorId))) .returning(), ) if (row === undefined) return yield* notFound() - return toConnector(row) + return toConnector(row, (yield* projectCounts(orgId)).get(row.id)) }) const scripts = Effect.fn("GcpConnectorService.scripts")(function* ( diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 0cdafb5c0e..10296e67aa 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -1,5 +1,5 @@ import { afterEach, assert, beforeAll, describe, it } from "@effect/vitest" -import { ConfigProvider, Effect, Exit, Layer, Logger, References, Tracer } from "effect" +import { ConfigProvider, Effect, Exit, Fiber, Layer, Logger, References, Tracer } from "effect" import { TestClock } from "effect/testing" import { FetchHttpClient } from "effect/http" import { GCP_ASSET_TYPES, GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" @@ -84,6 +84,8 @@ interface ScopedCall { readonly scope: string readonly params: URLSearchParams readonly authorization: string | null + /** `x-goog-user-project`: the project whose quota the call uses. */ + readonly quotaProject: string | null } interface MonitoringCall extends ScopedCall { @@ -95,6 +97,7 @@ interface Calls { readonly impersonate: Array<{ readonly account: string readonly authorization: string | null + readonly quotaProject: string | null readonly body: unknown }> readonly monitoring: Array @@ -107,7 +110,7 @@ interface StubOptions { /** Answer for one impersonation; undefined grants a token. */ readonly impersonate?: (account: string) => Response | undefined /** Answer for one `timeSeries.list` call; undefined answers "no series". */ - readonly monitoring?: (call: MonitoringCall) => Response | undefined + readonly monitoring?: (call: MonitoringCall) => Response | Promise | undefined /** Answer for one `searchAllResources` call; undefined answers "no resources". */ readonly assets?: (call: ScopedCall) => Response | undefined readonly ingestStatus?: number @@ -173,7 +176,9 @@ const stubFetch = (calls: Calls, options: StubOptions = {}) => const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, ) - const authorization = new Headers(init?.headers).get("authorization") + const headers = new Headers(init?.headers) + const authorization = headers.get("authorization") + const quotaProject = headers.get("x-goog-user-project") const body = await new Response(init?.body ?? null).text() if (url.host === "oauth2.googleapis.com") { calls.oauth.push(new URLSearchParams(body).get("assertion") ?? "") @@ -185,7 +190,7 @@ const stubFetch = (calls: Calls, options: StubOptions = {}) => .replace("/v1/projects/-/serviceAccounts/", "") .replace(":generateAccessToken", ""), ) - calls.impersonate.push({ account, authorization, body: JSON.parse(body) }) + calls.impersonate.push({ account, authorization, quotaProject, body: JSON.parse(body) }) return options.impersonate?.(account) ?? json({ accessToken: READER_TOKEN }) } if (url.host === "monitoring.googleapis.com") { @@ -195,15 +200,17 @@ const stubFetch = (calls: Calls, options: StubOptions = {}) => metricType: /metric\.type = "([^"]+)"/.exec(filter)?.[1] ?? "", params: url.searchParams, authorization, + quotaProject, } calls.monitoring.push(call) - return options.monitoring?.(call) ?? json({}) + return (await options.monitoring?.(call)) ?? json({}) } if (url.host === "cloudasset.googleapis.com") { const call = { scope: url.pathname.replace("/v1/", "").replace(":searchAllResources", ""), params: url.searchParams, authorization, + quotaProject, } calls.assets.push(call) return options.assets?.(call) ?? json({}) @@ -390,10 +397,12 @@ describe("GcpMetricsService", () => { ), ) + // Impersonation runs on Maple's own quota; everything read as the reader on its project's. assert.deepStrictEqual(calls.impersonate, [ { account: READER_A, authorization: `Bearer ${MAPLE_TOKEN}`, + quotaProject: null, body: { scope: ["https://www.googleapis.com/auth/cloud-platform"], lifetime: "600s", @@ -406,28 +415,31 @@ describe("GcpMetricsService", () => { calls.monitoring.every( (call) => call.scope === "projects/acme-prod" && - call.authorization === `Bearer ${READER_TOKEN}`, + call.authorization === `Bearer ${READER_TOKEN}` && + call.quotaProject === "acme-prod", ), ) const query = calls.monitoring.find((call) => call.metricType === REQUEST_COUNT)! - assert.deepStrictEqual(Object.fromEntries(query.params), { - filter: `metric.type = "${REQUEST_COUNT}" AND resource.type = "cloud_run_revision"`, - "interval.startTime": "2026-10-08T11:25:00.000Z", - "interval.endTime": "2026-10-08T12:25:00.000Z", - "aggregation.alignmentPeriod": "60s", - "aggregation.perSeriesAligner": "ALIGN_DELTA", - "aggregation.crossSeriesReducer": "REDUCE_SUM", - // Object.fromEntries keeps the last of a repeated parameter. - "aggregation.groupByFields": "metric.label.response_code_class", - view: "FULL", - pageSize: "5000", - }) - assert.deepStrictEqual(query.params.getAll("aggregation.groupByFields"), [ - "resource.label.project_id", - "resource.label.service_name", - "resource.label.location", - "metric.label.response_code_class", - ]) + assert.deepStrictEqual( + [...query.params], + [ + [ + "filter", + `metric.type = "${REQUEST_COUNT}" AND resource.type = "cloud_run_revision"`, + ], + ["interval.startTime", "2026-10-08T11:25:00.000Z"], + ["interval.endTime", "2026-10-08T12:25:00.000Z"], + ["aggregation.alignmentPeriod", "60s"], + ["aggregation.perSeriesAligner", "ALIGN_DELTA"], + ["aggregation.crossSeriesReducer", "REDUCE_SUM"], + ["aggregation.groupByFields", "resource.label.project_id"], + ["aggregation.groupByFields", "resource.label.service_name"], + ["aggregation.groupByFields", "resource.label.location"], + ["aggregation.groupByFields", "metric.label.response_code_class"], + ["view", "FULL"], + ["pageSize", "2000"], + ], + ) assert.lengthOf(calls.ingest, 1) assert.match(calls.ingest[0]!.authorization ?? "", /^Bearer maple_pk_/) @@ -664,7 +676,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.strictEqual( state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries returned more series than one poll reads; connect folders or projects separately to collect all of them.`, + `1 of ${METRIC_COUNT} metric queries held more than one poll reads; connect folders or projects separately to collect all of it.`, ) }), ) @@ -700,7 +712,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.match( state.last_metrics_error ?? "", - /^\d+ of \d+ metric queries returned more series/, + /^\d+ of \d+ metric queries held more than one poll reads/, ) }), ) @@ -753,6 +765,95 @@ describe("GcpMetricsService", () => { ) }) + it.effect("tries a query Google failed once more, then skips it for this window", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const flaky = "run.googleapis.com/request_latencies" + const broken = "run.googleapis.com/container/instance_count" + let flakyCalls = 0 + const stub = stubFetch(calls, { + monitoring: (call) => { + if (call.metricType === broken) return googleError(503, "UNAVAILABLE") + if (call.metricType === flaky && flakyCalls++ === 0) return googleError(500, "INTERNAL") + return onlyRequestCount(call) + }, + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) + assert.lengthOf(calls.monitoring, METRIC_COUNT + 2) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_metrics_error, + `1 of ${METRIC_COUNT} metric queries failed. First: ${broken}: Cloud Monitoring returned 503 UNAVAILABLE.`, + ) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) + }), + ) + }) + + it.effect("repeats the whole window when it runs out of quota before shipping anything", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const stub = stubFetch(calls, { + // The first query succeeds, the second is rate limited. + monitoring: (call) => + calls.monitoring.length === 1 + ? onlyRequestCount(call) + : googleError(429, "RESOURCE_EXHAUSTED"), + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 1, rowsIngested: 0 }) + assert.lengthOf(calls.monitoring, 2) + assert.lengthOf(calls.ingest, 0) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_metrics_error, + "Cloud Monitoring returned 429 RESOURCE_EXHAUSTED", + ) + assert.isNull(state.metrics_watermark_at) + }), + ) + }) + + it.effect("stops a poll at its time budget when Google does not answer, keeping the window", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + let reached = () => {} + const firstQuery = new Promise((resolve) => { + reached = resolve + }) + const stub = stubFetch(calls, { + monitoring: () => { + reached() + return new Promise(() => {}) + }, + }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + const fiber = yield* Effect.forkChild(pollAll) + yield* Effect.promise(() => firstQuery) + yield* TestClock.adjust("2 minutes") + assert.deepInclude(yield* Fiber.join(fiber), { polled: 1, failures: 1 }) + // Two queries, each tried twice at 20 seconds, pass the poll's minute; the rest wait. + assert.lengthOf(calls.monitoring, 4) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual(state.last_metrics_error, "Cloud Monitoring request timed out") + assert.isNull(state.metrics_watermark_at) + }), + ) + }) + it.effect("holds an organization that is over its plan limit for an hour", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() @@ -817,7 +918,11 @@ describe("GcpMetricsService", () => { Effect.gen(function* () { yield* insertConnector(testDb, CONNECTOR_A) const error = yield* Effect.flip(pollAll) - assert.strictEqual(error.message, "Google OAuth returned 400 invalid_grant") + assert.deepInclude(error, { + _tag: "@maple/api/integrations/GcpApiError", + kind: "invalid", + message: "Google OAuth returned 400 invalid_grant", + }) assert.lengthOf(calls.impersonate, 0) // Maple's own misconfiguration is not the customer's error. assert.isNull((yield* pollState(testDb, CONNECTOR_A)).last_metrics_error) @@ -856,6 +961,8 @@ describe("GcpMetricsService resource inventory", () => { let results: ReadonlyArray = [ project("acme-prod", "111"), runService("checkout"), + // The same name twice in one page is stored once. + { ...runService("cart"), displayName: "cart (listed twice)" }, runService("cart"), // Nothing to attribute it to: not stored. { name: "//cloudresourcemanager.googleapis.com/folders/42", assetType: "x/Folder" }, @@ -870,10 +977,14 @@ describe("GcpMetricsService resource inventory", () => { testDb, stub, Effect.gen(function* () { - yield* insertConnector(testDb, CONNECTOR_A) + yield* insertConnector(testDb, CONNECTOR_A, { scope: ["folder", "42"] }) yield* pollAll assert.lengthOf(calls.assets, 2) - assert.strictEqual(calls.assets[0]!.authorization, `Bearer ${READER_TOKEN}`) + assert.deepInclude(calls.assets[0], { + scope: "folders/42", + authorization: `Bearer ${READER_TOKEN}`, + quotaProject: "acme-prod", + }) assert.deepStrictEqual(calls.assets[0]!.params.getAll("assetTypes"), [...GCP_ASSET_TYPES]) assert.strictEqual(calls.assets[0]!.params.get("pageSize"), "500") assert.strictEqual( @@ -961,7 +1072,7 @@ describe("GcpMetricsService resource inventory", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_resources_error, - "The scope holds more than 20000 resources; the inventory is incomplete.", + "The scope holds more than 10000 resources; the inventory is incomplete.", ) // Counted as synced, so the next attempt is in an hour, and nothing was removed. assert.strictEqual(state.resources_synced_at?.getTime(), now + 60 * minute) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index 26c355beea..e3e9172a97 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -378,12 +378,14 @@ export class GcpMetricsService extends Context.Service= MAX_POINTS_PER_POLL || (yield* outOfTime)) { - incomplete += 1 + skipped += 1 continue } const result = yield* readMetric({ scope, group, metric, ...window }).pipe( @@ -392,9 +394,9 @@ export class GcpMetricsService extends Context.Service { displayName: "checkout", state: null, labels: { team: "payments" }, - createdAtMs: Date.UTC(2026, 0, 2, 3, 4, 5), - updatedAtMs: null, + resourceCreatedAt: new Date(Date.UTC(2026, 0, 2, 3, 4, 5)), + resourceUpdatedAt: null, }) const project = mapGcpResource({ diff --git a/packages/db/drizzle/20261008174147_gcp_metrics_poller/migration.sql b/packages/db/drizzle/20261008174147_gcp_metrics_poller/migration.sql new file mode 100644 index 0000000000..cc93de367b --- /dev/null +++ b/packages/db/drizzle/20261008174147_gcp_metrics_poller/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE "gcp_resources" ( + "connector_id" text, + "org_id" text NOT NULL, + "name" text, + "asset_type" text NOT NULL, + "project_id" text NOT NULL, + "location" text, + "display_name" text, + "state" text, + "labels" jsonb NOT NULL, + "resource_created_at" timestamp with time zone, + "resource_updated_at" timestamp with time zone, + "last_seen_at" timestamp with time zone NOT NULL, + CONSTRAINT "gcp_resources_pkey" PRIMARY KEY("connector_id","name") +); +--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "metrics_watermark_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "last_metrics_received_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "last_metrics_error" text;--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "metrics_lease_until" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "resources_synced_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "gcp_connectors" ADD COLUMN "last_resources_error" text;--> statement-breakpoint +ALTER TABLE "gcp_resources" ADD CONSTRAINT "gcp_resources_connector_id_gcp_connectors_id_fkey" FOREIGN KEY ("connector_id") REFERENCES "gcp_connectors"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/db/drizzle/20261008174147_gcp_metrics_poller/snapshot.json b/packages/db/drizzle/20261008174147_gcp_metrics_poller/snapshot.json new file mode 100644 index 0000000000..8327f175e4 --- /dev/null +++ b/packages/db/drizzle/20261008174147_gcp_metrics_poller/snapshot.json @@ -0,0 +1,18470 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "6203452b-5f04-402b-930b-1d30fc273787", + "prevIds": [ + "c2de92d1-c56e-4b42-b7af-16fa811a694e" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "agent_feedback", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "ai_triage_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_delivery_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_destinations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rule_claims", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rule_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "alert_rules", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_detector_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_detector_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "anomaly_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "api_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cancellation_reviews", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "chat_identities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "chat_workspaces", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_analytics_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_hyperdrive_configs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cloudflare_logpush_connectors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "gcp_connectors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "gcp_resources", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "cli_device_authorizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_authorizations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_clients", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mcp_oauth_refresh_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboard_shares", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboard_versions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "dashboards", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "digest_subscriptions", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "actors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_fingerprint_candidates", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_incidents", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_pull_requests", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issue_verifications", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_issues", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_notification_deliveries", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_notification_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "error_tick_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "issue_escalation_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "issue_escalations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "investigations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_auth_states", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "oauth_connections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_onboarding_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_attribute_mappings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_recommendation_issues", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_keys", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_ingest_sampling_policies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_clickhouse_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_clickhouse_schema_apply_runs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_connections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_databases", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_events", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_issue_receipts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "planetscale_poll_state", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "railway_connections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "railway_environments", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "scrape_target_checks", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "scrape_targets", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "org_support_channels", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_review_edits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_review_finding_embeddings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_review_findings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_review_replies", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_review_settings", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "pr_reviews", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_commits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_installations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_repositories", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "vcs_repository_branches", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "impact", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "summary", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "details", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "related_to", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_type", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_name", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_model", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_version", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "agent_feedback" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "20", + "generated": null, + "identity": null, + "name": "max_runs_per_day", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "90", + "generated": null, + "identity": null, + "name": "max_passes_per_day", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "ai_triage_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_id", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_key", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_type", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempt_number", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_by", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempted_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_message", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_reference", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "response_code", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config_json", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_iv", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_tag", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_tested_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_test_error", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_failures", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_failure_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_reason", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "alert_destinations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_name", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comparator", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_upper", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_value", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_delivered_event_type", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_notified_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hold_reason", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "held_since", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_issue_id", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'__total__'", + "generated": null, + "identity": null, + "name": "group_key", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_breaches", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_healthy", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_status", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_value", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rule_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notification_template_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_names_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exclude_service_names_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environments_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tags_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comparator", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_upper", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "window_minutes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "minimum_sample_count", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "consecutive_breaches_required", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "2", + "generated": null, + "identity": null, + "name": "consecutive_healthy_required", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "30", + "generated": null, + "identity": null, + "name": "renotify_interval_minutes", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apdex_threshold_ms", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "query_builder_draft_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "raw_query_sql", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "group_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_ids_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "query_spec_json", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reducer", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sample_count_strategy", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "no_data_behavior", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "alert_rules" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'normal'", + "generated": null, + "identity": null, + "name": "sensitivity", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "muted_signals_json", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_tick_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detector_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "deployment_env", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_breaches", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "consecutive_healthy", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_median", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "open_incident_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_resolved_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_incident_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detector_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "signal_type", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "deployment_env", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_issue_id", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "opened_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_median", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "baseline_sigma", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "threshold_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_value", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "last_sample_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolve_reason", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "triage_status", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "fingerprints_json", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "reopen_count", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_reopened_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key_hash", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "key_prefix", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "revoked", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_used_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metadata_json", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'standard'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_email", + "entityType": "columns", + "schema": "public", + "table": "api_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "plan_id", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subscription_started_at", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_at", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot_json", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "rule_reason", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "posted_at", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connector", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_name", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "chat_identities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connector", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_workspace_id", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_iv", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credentials_tag", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "chat_workspaces" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "zone_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "zone_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "watermark_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "backfill_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings_json", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "settings_fetched_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "quantiles_available", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discovered_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "live_scripts_json", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_until", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "config_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_host", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_port", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_scheme", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_database", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin_user", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "zone_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'http_requests'", + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_iv", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_tag", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_hash", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_received_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope_type", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "logs_enabled", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "metrics_enabled", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_iv", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_tag", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "secret_hash", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_received_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metrics_watermark_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_metrics_received_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_metrics_error", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "metrics_lease_until", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resources_synced_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_resources_error", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_connectors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connector_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "asset_type", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "display_name", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "labels", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_created_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource_updated_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "gcp_resources" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_code_hash", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_code_hash", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_org_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_roles", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_email", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api_key_id", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_iv", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_tag", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "request_id_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_name", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uri", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_challenge", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authorization_code_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_org_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_roles", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_user_email", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "approved_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "denied_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "used_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_name", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uris", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_uri", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_clients" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "family_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resource", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scopes", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "roles", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_email", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_key_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "replaced_by_id", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "family_expires_at", + "entityType": "columns", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "live_activity_start_token", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_name", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "disabled_reason", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_pushed_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dashboard_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "widget_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_hash", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_iv", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_tag", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_shares" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dashboard_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "version_number", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot_json", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "change_kind", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "change_summary", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_version_id", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboard_versions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "version", + "entityType": "columns", + "schema": "public", + "table": "dashboards" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "opted_out_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "day_of_week", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'UTC'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "namespaces_json", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "environments_json", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sent_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_attempted_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "web_analytics_enabled", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "web_analytics_opted_out_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "web_analytics_last_sent_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "web_analytics_last_attempted_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "agent_name", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "capabilities_json", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_active_at", + "entityType": "columns", + "schema": "public", + "table": "actors" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_type", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_message", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "error_label", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "top_frame", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "service_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_triggered_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_incidents" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "type", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "from_state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "to_state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_repo_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repo_full_name", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merge_commit_sha", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "link_source", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "linked_by_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_observed_occurrence_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_evaluated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "open_incident_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pull_request_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'waiting'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verify_after", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "baseline_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseline_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "baseline_rate_per_hour", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "investigation_id", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verdict", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "verdict_note", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "post_merge_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'error'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_ref_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "fingerprint_hash", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "fingerprint_version", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_type", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "exception_message", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "error_label", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "top_frame", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'triage'", + "generated": null, + "identity": null, + "name": "workflow_state", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "3", + "generated": null, + "identity": null, + "name": "priority", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity_source", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "assigned_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_holder_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "notes", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_seen_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_by_actor_id", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_resolved_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_regressed_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "regression_count", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "seen_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "resolved_versions_json", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snooze_until", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "archived_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "destination_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "delivery_key", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempt_count", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scheduled_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claimed_by", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "attempted_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "destination_ids_json", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notify_on_first_seen", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notify_on_regression", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_resolve", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_transition_in_review", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_transition_done", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "notify_on_claim", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "min_occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'warning'", + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "error_notification_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_through", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "bootstrap_completed", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_token", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "claim_expires_at", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "error_tick_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "rules_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "issue_escalation_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reason", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "run_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "investigation_id", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'[]'", + "generated": null, + "identity": null, + "name": "delivery_results_json", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'queued'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "attempts", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dedupe_key", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "issue_escalations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'investigating'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'user'", + "generated": null, + "identity": null, + "name": "seeded_by", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "subject_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "snapshot_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_kind", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issue_id", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "report_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "progress_json", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "confidence", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "input_tokens", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "output_tokens", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "autonomous_turns", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "diagnosed_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "investigations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "incident_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_id", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_reason", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "initiated_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "redirect_uri", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "return_to", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "code_verifier", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_user_email", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_account_name", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "granted_accounts_json", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connected_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_iv", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_tag", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_iv", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_tag", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "oauth_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "demo_data_requested", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "onboarding_completed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checklist_dismissed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "first_data_received_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "welcome_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connect_nudge_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "stalled_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activation_email_sent_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reward_claimed_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reward_reserved_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_onboarding_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_context", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "operation", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recommendation_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canonical_key", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "usage_count", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "opened_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_at", + "entityType": "columns", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_key_hash", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_iv", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_tag", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_key_hash", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "public_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "private_rotated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "double precision", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "1", + "generated": null, + "identity": null, + "name": "trace_sample_ratio", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "always_keep_error_spans", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "always_keep_slow_spans_ms", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_ingest_sampling_policies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_url", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_user", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_iv", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_password_tag", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ch_database", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "schema_version", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workflow_instance_id", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "phase", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "current_migration", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "steps_total", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "steps_done", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "applied_versions", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skipped", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error_message", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ps_organization", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connected_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scrape_target_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_iv", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "webhook_secret_tag", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "detected_permissions_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_inventory_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_inventory_error", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'mysql'", + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "region", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "plan", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "branches_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "deleted_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_databases" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "database_name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "branch_name", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_type", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "external_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "actor_login", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "payload_json", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurred_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_events" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "processed_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dataset", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "database_id", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "watermark_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_until", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_iv", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_tag", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "workspace_names", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connected_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_failed_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discovered_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "lease_until", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "railway_connections" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "connection_id", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_id", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "project_name", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_name", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'{}'", + "generated": null, + "identity": null, + "name": "services_json", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "watermark_at", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_success_at", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_error_at", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "railway_environments" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": { + "type": "byDefault", + "name": "scrape_target_checks_id_seq", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": 1, + "cycle": false + }, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "target_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "sub_target_key", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "checked_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "duration_ms", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "samples_scraped", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "samples_post_relabel", + "entityType": "columns", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "service_name", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'prometheus'", + "generated": null, + "identity": null, + "name": "target_type", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "discovery_config_json", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "15", + "generated": null, + "identity": null, + "name": "scrape_interval_seconds", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "labels_json", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'none'", + "generated": null, + "identity": null, + "name": "auth_type", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "managed_by", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_ciphertext", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_iv", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "auth_credentials_tag", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "enabled", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scrape_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_scrape_error", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "scrape_targets" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slack_channel_id", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slack_channel_name", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reservation_id", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reserved_at", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "org_support_channels" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reply_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "seq", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "old_text", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "new_text", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_edits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finding_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "real", + "typeSchema": null, + "notNull": true, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "embedding", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "review_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "handle", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "path", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "line", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "category", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "severity", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'open'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "resolved_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "reactions_up", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "reactions_down", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_findings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "surface", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_root_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "command", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "head_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'queued'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reply_url", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "commit_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_replies" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_review_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "pr_review_settings" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "defaults", + "entityType": "columns", + "schema": "public", + "table": "pr_review_settings" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "pr_review_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_by", + "entityType": "columns", + "schema": "public", + "table": "pr_review_settings" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "number", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "head_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "base_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'queued'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "skip_reason", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "session_id", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "comment_attempt", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "report_json", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "score", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "check_run_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "comment_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "review_url", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "publish_error", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "error", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "model", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "input_tokens", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "output_tokens", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "started_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "finished_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merged_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "telemetry_json", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "merge_commit_sha", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_merge_status", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_merge_after", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "post_merge_json", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "pr_reviews" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "sha", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "message", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_name", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_email", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_login", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "author_avatar_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "authored_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "committed_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "html_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_commits" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_installation_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_login", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_type", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_account_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_avatar_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'all'", + "generated": null, + "identity": null, + "name": "repository_selection", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "suspended_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installed_by_user_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_installations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "installation_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "external_repo_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "owner", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "full_name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'main'", + "generated": null, + "identity": null, + "name": "default_branch", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tracked_branch", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "html_url", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "is_private", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_archived", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'active'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "sync_status", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_synced_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_sync_error", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "pr_review_enabled", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "pr_review_config", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repositories" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "org_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "repository_id", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "is_default", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "head_sha", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "agent_feedback_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "agent_feedback" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "agent_feedback_kind_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "agent_feedback" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_org_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "attempt_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_delivery_events_delivery_attempt_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_delivery_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_destinations_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_destinations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rule_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_rule_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "error_issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "incident_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_incident_key_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "rule_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "group_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'open'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_incidents_open_group_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rule_claims_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rule_claims" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rule_states_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rule_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "alert_rules_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "alert_rules" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "open_incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"open_incident_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_detector_states_open_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "last_evaluated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_detector_states_evaluated_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_status_triggered_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_triggered_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "detector_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_detector_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "error_issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "detector_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" = 'open'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "anomaly_incidents_open_detector_idx", + "entityType": "indexes", + "schema": "public", + "table": "anomaly_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "api_keys_org_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "api_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "plan_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "subscription_started_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cancellation_reviews_subscription_idx", + "entityType": "indexes", + "schema": "public", + "table": "cancellation_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_connector_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_connector_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_identities_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_identities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connector", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_workspace_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_workspaces_connector_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "chat_workspaces_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "chat_workspaces" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dataset", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "zone_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cf_analytics_state_org_account_dataset_zone_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cf_analytics_state_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_analytics_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "config_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_hyperdrive_configs_org_config_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_hyperdrive_configs_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_hyperdrive_configs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cloudflare_logpush_connectors_secret_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "cloudflare_logpush_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scope_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scope_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "gcp_connectors_org_scope_idx", + "entityType": "indexes", + "schema": "public", + "table": "gcp_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "secret_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "gcp_connectors_secret_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "gcp_connectors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_code_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cli_device_authorizations_user_code_unique", + "entityType": "indexes", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "cli_device_authorizations_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "cli_device_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "authorization_code_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_authorizations_code_unique", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_authorizations_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_authorizations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "family_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_family_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mcp_oauth_refresh_tokens_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "mcp_oauth_refresh_tokens" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "platform", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_org_platform_token_unique", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "mobile_devices_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "token_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_token_hash_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "coalesce(widget_id, '')", + "isExpression": true, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "revoked_at is null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_live_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_org_dashboard_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_shares_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_versions_org_dashboard_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_versions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dashboard_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "version_number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboard_versions_org_dashboard_version_unq", + "entityType": "indexes", + "schema": "public", + "table": "dashboard_versions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "updated_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboards_org_updated_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboards" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "dashboards_org_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "dashboards" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "digest_subscriptions_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "digest_subscriptions_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "digest_subscriptions" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_user_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "agent_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_agent_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "actors_org_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "actors" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_fingerprint_candidates_last_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_incidents_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_triggered_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_incidents_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_incidents" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_actor_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_events_type_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "repo_full_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_issue_pr_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "repo_full_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_repo_number_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_pull_requests_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_pull_requests" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "verify_after", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"status\" in ('waiting', 'running')", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issue_verifications_open_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issue_verifications" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fingerprint_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_fp_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "workflow_state", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_workflow_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "severity", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_severity_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "last_seen_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archived_at\" is null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_live_seen_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "fingerprint_version", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_fp_version_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "assigned_actor_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_assignee_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "lease_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_lease_expiry_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "archived_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"archived_at\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_issues_org_archived_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "scheduled_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "delivery_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "destination_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_notification_deliveries_key_destination_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_notification_deliveries" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "claim_expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "error_tick_states_claim_idx", + "entityType": "indexes", + "schema": "public", + "table": "error_tick_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "dedupe_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_dedupe_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "issue_escalations_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "issue_escalations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_kind", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": "\"incident_id\" is not null", + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "issue_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_issue_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "investigations_org_status_idx", + "entityType": "indexes", + "schema": "public", + "table": "investigations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "device_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_activities_device_incident_unique", + "entityType": "indexes", + "schema": "public", + "table": "live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "incident_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "live_activities_incident_idx", + "entityType": "indexes", + "schema": "public", + "table": "live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_auth_states_expires_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_auth_states" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_connections_org_provider_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "oauth_connections_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "oauth_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_attribute_mappings_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_attribute_mappings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_recommendation_issues_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "recommendation_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_recommendation_issues_org_key_idx", + "entityType": "indexes", + "schema": "public", + "table": "org_recommendation_issues" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_public_key_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "public_key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_public_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "private_key_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "org_ingest_keys_private_key_hash_unique", + "entityType": "indexes", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_connections_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_databases_org_db_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_databases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_databases_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_databases" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "event_type", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_dedupe_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_org_db_time_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurred_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_events_org_time_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_events" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "processed_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_issue_receipts_processed_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "dataset", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "database_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_poll_state_org_dataset_db_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "planetscale_poll_state_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "planetscale_poll_state" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "railway_connections_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "railway_connections" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "railway_environments_org_env_idx", + "entityType": "indexes", + "schema": "public", + "table": "railway_environments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "connection_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "railway_environments_connection_idx", + "entityType": "indexes", + "schema": "public", + "table": "railway_environments" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "target_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "checked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_target_checks_target_checked_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_targets_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_targets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "enabled", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "scrape_targets_org_enabled_idx", + "entityType": "indexes", + "schema": "public", + "table": "scrape_targets" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "reply_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "seq", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_edits_reply_seq_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_edits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "model", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_finding_embeddings_org_model_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_finding_embeddings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "handle", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_findings_pr_handle_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_findings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_findings_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_findings" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "comment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_replies_repo_comment_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_review_replies_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_review_replies" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "head_sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_repo_number_head_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "number", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_repo_number_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_org_created_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "post_merge_after", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"post_merge_status\" = 'waiting'", + "with": "", + "method": "btree", + "concurrently": false, + "name": "pr_reviews_post_merge_due_idx", + "entityType": "indexes", + "schema": "public", + "table": "pr_reviews" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_commits_repo_sha_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_commits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "sha", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_commits_org_sha_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_commits" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_installation_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_installations_provider_external_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_installations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_installations_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_installations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "provider", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "external_repo_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_org_repo_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "installation_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repositories_installation_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repositories" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "repository_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repository_branches_repo_name_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "org_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "vcs_repository_branches_org_idx", + "entityType": "indexes", + "schema": "public", + "table": "vcs_repository_branches" + }, + { + "nameExplicit": false, + "columns": [ + "connector_id" + ], + "schemaTo": "public", + "tableTo": "gcp_connectors", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "gcp_resources_connector_id_gcp_connectors_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "gcp_resources" + }, + { + "nameExplicit": true, + "columns": [ + "org_id", + "dashboard_id" + ], + "schemaTo": "public", + "tableTo": "dashboards", + "columnsTo": [ + "org_id", + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "dashboard_shares_dashboard_fk", + "entityType": "fks", + "schema": "public", + "table": "dashboard_shares" + }, + { + "nameExplicit": false, + "columns": [ + "target_id" + ], + "schemaTo": "public", + "tableTo": "scrape_targets", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "scrape_target_checks_target_id_scrape_targets_id_fk", + "entityType": "fks", + "schema": "public", + "table": "scrape_target_checks" + }, + { + "columns": [ + "org_id", + "rule_id", + "group_key" + ], + "nameExplicit": false, + "name": "alert_rule_states_org_id_rule_id_group_key_pk", + "entityType": "pks", + "schema": "public", + "table": "alert_rule_states" + }, + { + "columns": [ + "org_id", + "detector_key" + ], + "nameExplicit": false, + "name": "anomaly_detector_states_org_id_detector_key_pk", + "entityType": "pks", + "schema": "public", + "table": "anomaly_detector_states" + }, + { + "columns": [ + "connector_id", + "name" + ], + "nameExplicit": false, + "name": "gcp_resources_pkey", + "entityType": "pks", + "schema": "public", + "table": "gcp_resources" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboard_shares_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboard_shares" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboard_versions_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboard_versions" + }, + { + "columns": [ + "org_id", + "id" + ], + "nameExplicit": false, + "name": "dashboards_org_id_id_pk", + "entityType": "pks", + "schema": "public", + "table": "dashboards" + }, + { + "columns": [ + "org_id", + "fingerprint_hash" + ], + "nameExplicit": false, + "name": "error_fingerprint_candidates_org_id_fingerprint_hash_pk", + "entityType": "pks", + "schema": "public", + "table": "error_fingerprint_candidates" + }, + { + "columns": [ + "org_id", + "issue_id" + ], + "nameExplicit": false, + "name": "error_issue_states_org_id_issue_id_pk", + "entityType": "pks", + "schema": "public", + "table": "error_issue_states" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_ingest_keys_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_ingest_keys" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_clickhouse_settings_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_clickhouse_settings" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_clickhouse_schema_apply_runs_org_id_pk", + "entityType": "pks", + "schema": "public", + "table": "org_clickhouse_schema_apply_runs" + }, + { + "columns": [ + "org_id", + "event_id" + ], + "nameExplicit": false, + "name": "planetscale_issue_receipts_org_id_event_id_pk", + "entityType": "pks", + "schema": "public", + "table": "planetscale_issue_receipts" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "agent_feedback_pkey", + "schema": "public", + "table": "agent_feedback", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "ai_triage_settings_pkey", + "schema": "public", + "table": "ai_triage_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_delivery_events_pkey", + "schema": "public", + "table": "alert_delivery_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_destinations_pkey", + "schema": "public", + "table": "alert_destinations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_incidents_pkey", + "schema": "public", + "table": "alert_incidents", + "entityType": "pks" + }, + { + "columns": [ + "rule_id" + ], + "nameExplicit": false, + "name": "alert_rule_claims_pkey", + "schema": "public", + "table": "alert_rule_claims", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "alert_rules_pkey", + "schema": "public", + "table": "alert_rules", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "anomaly_detector_settings_pkey", + "schema": "public", + "table": "anomaly_detector_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "anomaly_incidents_pkey", + "schema": "public", + "table": "anomaly_incidents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "api_keys_pkey", + "schema": "public", + "table": "api_keys", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cancellation_reviews_pkey", + "schema": "public", + "table": "cancellation_reviews", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_identities_pkey", + "schema": "public", + "table": "chat_identities", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "chat_workspaces_pkey", + "schema": "public", + "table": "chat_workspaces", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_analytics_state_pkey", + "schema": "public", + "table": "cloudflare_analytics_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_hyperdrive_configs_pkey", + "schema": "public", + "table": "cloudflare_hyperdrive_configs", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "cloudflare_logpush_connectors_pkey", + "schema": "public", + "table": "cloudflare_logpush_connectors", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "gcp_connectors_pkey", + "schema": "public", + "table": "gcp_connectors", + "entityType": "pks" + }, + { + "columns": [ + "device_code_hash" + ], + "nameExplicit": false, + "name": "cli_device_authorizations_pkey", + "schema": "public", + "table": "cli_device_authorizations", + "entityType": "pks" + }, + { + "columns": [ + "request_id_hash" + ], + "nameExplicit": false, + "name": "mcp_oauth_authorizations_pkey", + "schema": "public", + "table": "mcp_oauth_authorizations", + "entityType": "pks" + }, + { + "columns": [ + "client_id" + ], + "nameExplicit": false, + "name": "mcp_oauth_clients_pkey", + "schema": "public", + "table": "mcp_oauth_clients", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mcp_oauth_refresh_tokens_pkey", + "schema": "public", + "table": "mcp_oauth_refresh_tokens", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "mobile_devices_pkey", + "schema": "public", + "table": "mobile_devices", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "digest_subscriptions_pkey", + "schema": "public", + "table": "digest_subscriptions", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "actors_pkey", + "schema": "public", + "table": "actors", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_incidents_pkey", + "schema": "public", + "table": "error_incidents", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_events_pkey", + "schema": "public", + "table": "error_issue_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_pull_requests_pkey", + "schema": "public", + "table": "error_issue_pull_requests", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issue_verifications_pkey", + "schema": "public", + "table": "error_issue_verifications", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_issues_pkey", + "schema": "public", + "table": "error_issues", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "error_notification_deliveries_pkey", + "schema": "public", + "table": "error_notification_deliveries", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "error_notification_policies_pkey", + "schema": "public", + "table": "error_notification_policies", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "error_tick_states_pkey", + "schema": "public", + "table": "error_tick_states", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "issue_escalation_policies_pkey", + "schema": "public", + "table": "issue_escalation_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "issue_escalations_pkey", + "schema": "public", + "table": "issue_escalations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "investigations_pkey", + "schema": "public", + "table": "investigations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "live_activities_pkey", + "schema": "public", + "table": "live_activities", + "entityType": "pks" + }, + { + "columns": [ + "state" + ], + "nameExplicit": false, + "name": "oauth_auth_states_pkey", + "schema": "public", + "table": "oauth_auth_states", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "oauth_connections_pkey", + "schema": "public", + "table": "oauth_connections", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_onboarding_state_pkey", + "schema": "public", + "table": "org_onboarding_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "org_ingest_attribute_mappings_pkey", + "schema": "public", + "table": "org_ingest_attribute_mappings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "org_recommendation_issues_pkey", + "schema": "public", + "table": "org_recommendation_issues", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_ingest_sampling_policies_pkey", + "schema": "public", + "table": "org_ingest_sampling_policies", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_connections_pkey", + "schema": "public", + "table": "planetscale_connections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_databases_pkey", + "schema": "public", + "table": "planetscale_databases", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_events_pkey", + "schema": "public", + "table": "planetscale_events", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "planetscale_poll_state_pkey", + "schema": "public", + "table": "planetscale_poll_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "railway_connections_pkey", + "schema": "public", + "table": "railway_connections", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "railway_environments_pkey", + "schema": "public", + "table": "railway_environments", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "scrape_target_checks_pkey", + "schema": "public", + "table": "scrape_target_checks", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "scrape_targets_pkey", + "schema": "public", + "table": "scrape_targets", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_support_channels_pkey", + "schema": "public", + "table": "org_support_channels", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pr_review_edits_pkey", + "schema": "public", + "table": "pr_review_edits", + "entityType": "pks" + }, + { + "columns": [ + "finding_id" + ], + "nameExplicit": false, + "name": "pr_review_finding_embeddings_pkey", + "schema": "public", + "table": "pr_review_finding_embeddings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pr_review_findings_pkey", + "schema": "public", + "table": "pr_review_findings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pr_review_replies_pkey", + "schema": "public", + "table": "pr_review_replies", + "entityType": "pks" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "pr_review_settings_pkey", + "schema": "public", + "table": "pr_review_settings", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "pr_reviews_pkey", + "schema": "public", + "table": "pr_reviews", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_commits_pkey", + "schema": "public", + "table": "vcs_commits", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_installations_pkey", + "schema": "public", + "table": "vcs_installations", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_repositories_pkey", + "schema": "public", + "table": "vcs_repositories", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "vcs_repository_branches_pkey", + "schema": "public", + "table": "vcs_repository_branches", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/domain/src/gcp-metrics.test.ts b/packages/domain/src/gcp-metrics.test.ts index 9cb6bc2ed0..0358783522 100644 --- a/packages/domain/src/gcp-metrics.test.ts +++ b/packages/domain/src/gcp-metrics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { GCP_ASSET_TYPES, GCP_METRIC_GROUPS, GCP_PROJECT_ASSET_TYPE } from "./gcp-metrics" +import { GCP_METRIC_GROUPS } from "./gcp-metrics" describe("GCP_METRIC_GROUPS", () => { const metrics = GCP_METRIC_GROUPS.flatMap((group) => group.metrics) @@ -12,25 +12,12 @@ describe("GCP_METRIC_GROUPS", () => { "cloudsql.googleapis.com/database/postgresql/num_backends": "gcp.cloudsql.database.postgresql.num_backends", }) + // No type twice, and no two types under one name. + expect(Object.keys(names)).toHaveLength(metrics.length) expect(new Set(Object.values(names)).size).toBe(metrics.length) - expect(new Set(Object.keys(names)).size).toBe(metrics.length) - }) - - it("aligns counters and distributions as deltas, gauges as means", () => { - for (const metric of metrics) { - expect(metric.aligner, metric.type).toBe(metric.kind === "gauge" ? "ALIGN_MEAN" : "ALIGN_DELTA") - if (metric.kind !== "gauge") expect(metric.reducer, metric.type).toBe("REDUCE_SUM") - } }) it("never groups by the project itself: every query adds it", () => { for (const group of GCP_METRIC_GROUPS) expect(group.resourceLabels).not.toContain("project_id") }) }) - -describe("GCP_ASSET_TYPES", () => { - it("lists projects, which is how a folder or organization scope is discovered", () => { - expect(GCP_ASSET_TYPES).toContain(GCP_PROJECT_ASSET_TYPE) - expect(new Set(GCP_ASSET_TYPES).size).toBe(GCP_ASSET_TYPES.length) - }) -}) diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts index ee6f1bc98a..f6772df650 100644 --- a/packages/domain/src/http/v2/integrations-gcp.ts +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -34,6 +34,10 @@ const connectorExample = { created_at: "2026-10-01T12:00:00.000Z", last_log_received_at: "2026-10-08T09:12:00.000Z", last_log_error: null, + last_metrics_received_at: "2026-10-08T09:10:00.000Z", + last_metrics_error: null, + discovered_project_count: 14, + last_resources_error: null, } as const export const V2GcpConnector = Schema.Struct({ @@ -73,6 +77,24 @@ export const V2GcpConnector = Schema.Struct({ description: "Why the most recent log push was rejected, or `null`.", examples: [null], }), + last_metrics_received_at: Schema.NullOr(Timestamp).annotate({ + description: + "When Maple last read the scope's metrics, or `null` before the first successful read. Stays `null` until the setup script has run with `metrics_enabled`.", + }), + last_metrics_error: Schema.NullOr(Schema.String).annotate({ + description: + "Why the most recent metrics read failed or was incomplete, or `null`. Before the setup script has run it says that Maple has no access yet.", + examples: [null], + }), + discovered_project_count: Schema.Number.annotate({ + description: + "How many projects the latest resource sync found in the scope. `0` until the first sync; a `project` scope reports `1`.", + examples: [14], + }), + last_resources_error: Schema.NullOr(Schema.String).annotate({ + description: "Why the most recent resource sync failed or was incomplete, or `null`.", + examples: [null], + }), }).annotate({ identifier: "GcpConnector", title: "Google Cloud connector", From de8050d9b29ab2ffca6c900905d8a2387c82828f Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 20:14:11 +0200 Subject: [PATCH 03/17] fix(integrations): tighten the Google Cloud poller's window, budget and failure rules --- .../integrations/GcpConnectorService.test.ts | 21 +- .../integrations/GcpConnectorService.ts | 29 +- .../integrations/GcpMetricsService.test.ts | 155 ++++--- .../integrations/GcpMetricsService.ts | 204 +++++----- .../src/services/integrations/gcp/api.ts | 34 +- .../src/services/integrations/gcp/mapping.ts | 13 +- packages/db/src/schema/gcp-connectors.ts | 3 +- packages/db/src/schema/gcp-resources.ts | 7 +- packages/domain/src/gcp-metrics.ts | 377 +++++++----------- 9 files changed, 362 insertions(+), 481 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index a7f59560d1..dda42d9d1d 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -202,7 +202,7 @@ describe("GcpConnectorService", () => { }).pipe(Effect.provide(makeLayer(testDb))) }) - it.effect("reports what the poller recorded, and restarts it when metrics are switched", () => { + it.effect("reports what the poller recorded and how many projects it found", () => { const testDb = createTestDb(trackedDbs) return Effect.gen(function* () { const gcp = yield* GcpConnectorService @@ -263,20 +263,11 @@ describe("GcpConnectorService", () => { // Both were created in the same test-clock instant, so their order is by id. assert.sameDeepMembers([...(yield* gcp.status(orgId)).connectors], [polled, other]) - // A change that leaves metrics alone keeps the poller's position. - assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, { logsEnabled: true }), polled) - - // Off and on again: no replay of the gap, no error from before, a fresh inventory sync. - const off = yield* gcp.update(orgId, connector.id, { metricsEnabled: false }) - assert.deepInclude(off, { lastMetricsError: null, lastResourcesError: null }) - const state = yield* Effect.promise(() => - queryFirstRow<{ metrics_watermark_at: unknown; resources_synced_at: unknown }>( - testDb, - "SELECT metrics_watermark_at, resources_synced_at FROM gcp_connectors WHERE id = $1", - [connector.id], - ), - ) - assert.deepStrictEqual(state, { metrics_watermark_at: null, resources_synced_at: null }) + // Switching a flag leaves the poller's state as it is. + assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, { metricsEnabled: false }), { + ...polled, + metricsEnabled: false, + }) // The inventory goes with its connector. yield* gcp.delete(orgId, connector.id) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index b4ee29aa6f..8d42a7a7af 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -22,7 +22,7 @@ import { type OrgId, type UserId, } from "@maple/domain/primitives" -import { and, asc, eq, sql } from "drizzle-orm" +import { and, asc, eq, inArray, sql } from "drizzle-orm" import { Clock, Context, Effect, Layer, Option, Redacted } from "effect" import { decryptAes256Gcm, encryptAes256Gcm, parseBase64Aes256GcmKey } from "@maple/backend/platform/Crypto" import { Database } from "@maple/backend/platform/DatabaseLive" @@ -189,14 +189,14 @@ export class GcpConnectorService extends Context.Service + const projectCounts = (connectorIds: ReadonlyArray) => dbExecute((db) => db .select({ connectorId: gcpResources.connectorId, count: sql`count(*)::int` }) .from(gcpResources) .where( and( - eq(gcpResources.orgId, orgId), + inArray(gcpResources.connectorId, [...connectorIds]), eq(gcpResources.assetType, GCP_PROJECT_ASSET_TYPE), ), ) @@ -212,7 +212,11 @@ export class GcpConnectorService extends Context.Service() + : yield* projectCounts(rows.map((row) => row.id)) return { metricsAvailable: mapleServiceAccountEmail !== undefined, connectors: rows.map((row) => toConnector(row, counts.get(row.id))), @@ -288,25 +292,12 @@ export class GcpConnectorService extends Context.Service db .update(gcpConnectors) - .set({ - ...flags, - // Switching metrics restarts the poller from a fresh window: it does not - // replay the time they were off, or keep an error from before. - ...(flags.metricsEnabled === current.metricsEnabled - ? undefined - : { - metricsWatermarkAt: null, - lastMetricsError: null, - resourcesSyncedAt: null, - lastResourcesError: null, - }), - updatedAt, - }) + .set({ ...flags, updatedAt }) .where(and(eq(gcpConnectors.orgId, orgId), eq(gcpConnectors.id, connectorId))) .returning(), ) if (row === undefined) return yield* notFound() - return toConnector(row, (yield* projectCounts(orgId)).get(row.id)) + return toConnector(row, (yield* projectCounts([row.id])).get(row.id)) }) const scripts = Effect.fn("GcpConnectorService.scripts")(function* ( diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 10296e67aa..6dc78c62d4 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -31,11 +31,11 @@ describe("nextWindow", () => { assert.isNull(nextWindow(new Date(horizon), now)) }) - it("catches up a long gap one hour per poll, skipping nothing", () => { + it("never reaches back more than an hour: a long pause is skipped, not replayed", () => { const watermark = new Date(horizon - 48 * 60 * minute) assert.deepStrictEqual(nextWindow(watermark, now), { - startMs: watermark.getTime(), - endMs: watermark.getTime() + 60 * minute, + startMs: horizon - 60 * minute, + endMs: horizon, }) }) }) @@ -372,8 +372,6 @@ describe("GcpMetricsService", () => { yield* insertConnector(testDb, CONNECTOR_A) const summary = yield* pollAll assert.deepInclude(summary, { polled: 1, deferred: 0, rowsIngested: 2, failures: 0 }) - // Sign-in, impersonation, inventory page, one query per curated metric, ingest post. - assert.strictEqual(summary.calls, 4 + METRIC_COUNT) // The assertion is an RS256 JWT from Maple's account, valid for ten minutes. assert.lengthOf(calls.oauth, 1) @@ -420,7 +418,7 @@ describe("GcpMetricsService", () => { ), ) const query = calls.monitoring.find((call) => call.metricType === REQUEST_COUNT)! - assert.deepStrictEqual( + assert.includeDeepMembers( [...query.params], [ [ @@ -436,8 +434,6 @@ describe("GcpMetricsService", () => { ["aggregation.groupByFields", "resource.label.service_name"], ["aggregation.groupByFields", "resource.label.location"], ["aggregation.groupByFields", "metric.label.response_code_class"], - ["view", "FULL"], - ["pageSize", "2000"], ], ) @@ -470,7 +466,6 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.strictEqual(state.last_metrics_received_at?.getTime(), now) assert.isNull(state.last_metrics_error) - assert.strictEqual(state.metrics_lease_until?.getTime(), now + 4 * minute) // No credential in anything the tick logged or traced. const recorded = recorder.text() @@ -559,6 +554,19 @@ describe("GcpMetricsService", () => { assert.strictEqual(second.get("interval.endTime"), "2026-10-08T12:30:00.000Z") const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon + 5 * minute) + + // Metrics that were off for days resume with the last hour, not a replay of the gap. + yield* Effect.promise(() => + executeSql(testDb, "UPDATE gcp_connectors SET metrics_watermark_at = $1", [ + "2026-10-01T00:00:00.000Z", + ]), + ) + yield* TestClock.setTime(now + 10 * minute) + yield* pollAll + assert.strictEqual( + calls.monitoring.at(-1)!.params.get("interval.startTime"), + "2026-10-08T11:35:00.000Z", + ) }), ) }) @@ -660,16 +668,8 @@ describe("GcpMetricsService", () => { assert.deepInclude(summary, { polled: 1, failures: 0, incompleteMetrics: 1 }) const pages = calls.monitoring.filter((call) => call.metricType === REQUEST_COUNT) assert.lengthOf(pages, MAX_PAGES_PER_METRIC) - assert.deepStrictEqual( - pages.map((call) => call.params.get("pageToken")), - [ - null, - ...Array.from( - { length: MAX_PAGES_PER_METRIC - 1 }, - (_, index) => `page-${index + 1}`, - ), - ], - ) + assert.isNull(pages[0]!.params.get("pageToken")) + assert.isNotNull(pages.at(-1)!.params.get("pageToken")) // What was read is kept, and the window is not read again. assert.strictEqual(summary.rowsIngested, 2 * MAX_PAGES_PER_METRIC) const state = yield* pollState(testDb, CONNECTOR_A) @@ -682,38 +682,60 @@ describe("GcpMetricsService", () => { ) }) - it.effect("stops reading further metrics once a poll has read its share of points", () => { + it.effect("stops every poll in flight once the tick has made its share of requests", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() - // 25,000 points per metric, all before the window: read and counted, nothing to store. - const heavy = JSON.stringify({ - timeSeries: Array.from({ length: 10 }, (_, index) => ({ - metric: {}, - resource: { labels: { project_id: `p-${index}` } }, - points: Array.from({ length: 2_500 }, () => ({ - interval: { endTime: "2026-10-08T10:00:00Z" }, - value: { doubleValue: 1 }, - })), - })), - }) + // Every query has another page, so a connector alone would make 460 requests. + const stub = stubFetch(calls, { monitoring: () => json({ nextPageToken: "more" }) }) + const connectorId = (index: number) => `0${index.toString(16)}8f2b3c-4d5e-4f70-8192-a3b4c5d6e7f8` + return run( + testDb, + stub, + Effect.gen(function* () { + // Two organizations, so the per-organization cap is not what stops them. + for (let index = 0; index < 12; index++) { + yield* insertConnector(testDb, connectorId(index), { + orgId: index < 6 ? "org_gcp" : "org_other", + projectId: `acme-prod-${index}`, + }) + } + const summary = yield* pollAll + // Three polls run at once, so the gate can be passed by a request or two each. + assert.isAtLeast(summary.calls, 3_000) + assert.isBelow(summary.calls, 3_010) + // The polls in flight stopped mid-table, and the connectors behind them never started. + assert.isAbove(summary.incompleteMetrics, 0) + assert.isAbove(summary.deferred, 0) + }), + ) + }) + + it.effect("reads a metric type the scope never used as empty, not as a failure", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + let everyTypeUnknown = false const stub = stubFetch(calls, { - monitoring: () => new Response(heavy, { headers: { "content-type": "application/json" } }), + monitoring: (call) => + everyTypeUnknown || call.metricType !== REQUEST_COUNT + ? googleError(404, "NOT_FOUND") + : json(requestCountSeries()), }) return run( testDb, stub, Effect.gen(function* () { yield* insertConnector(testDb, CONNECTOR_A) - const summary = yield* pollAll - // Four queries reach 100,000 points; the rest wait for a smaller scope. - assert.lengthOf(calls.monitoring, 4) - assert.deepInclude(summary, { polled: 1, failures: 0, incompleteMetrics: METRIC_COUNT - 4 }) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) + assert.lengthOf(calls.monitoring, METRIC_COUNT) + assert.isNull((yield* pollState(testDb, CONNECTOR_A)).last_metrics_error) + + // A scope with nothing in it is an empty poll that still moves on. + everyTypeUnknown = true + yield* TestClock.setTime(now + 5 * minute) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 0 }) const state = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) - assert.match( - state.last_metrics_error ?? "", - /^\d+ of \d+ metric queries held more than one poll reads/, - ) + assert.isNull(state.last_metrics_error) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon + 5 * minute) }), ) }) @@ -795,14 +817,14 @@ describe("GcpMetricsService", () => { ) }) - it.effect("repeats the whole window when it runs out of quota before shipping anything", () => { + it.effect("stops querying when the quota runs out, keeping and moving past what it read", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() const stub = stubFetch(calls, { - // The first query succeeds, the second is rate limited. - monitoring: (call) => + // The first query is answered, the second is rate limited. + monitoring: () => calls.monitoring.length === 1 - ? onlyRequestCount(call) + ? json(requestCountSeries()) : googleError(429, "RESOURCE_EXHAUSTED"), }) return run( @@ -810,15 +832,16 @@ describe("GcpMetricsService", () => { stub, Effect.gen(function* () { yield* insertConnector(testDb, CONNECTOR_A) - assert.deepInclude(yield* pollAll, { polled: 1, failures: 1, rowsIngested: 0 }) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0 }) assert.lengthOf(calls.monitoring, 2) - assert.lengthOf(calls.ingest, 0) const state = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual( - state.last_metrics_error, - "Cloud Monitoring returned 429 RESOURCE_EXHAUSTED", + assert.match( + state.last_metrics_error ?? "", + new RegExp( + `^${METRIC_COUNT - 1} of ${METRIC_COUNT} metric queries failed\\. First: .+: Cloud Monitoring returned 429 RESOURCE_EXHAUSTED\\.$`, + ), ) - assert.isNull(state.metrics_watermark_at) + assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) }), ) }) @@ -845,8 +868,8 @@ describe("GcpMetricsService", () => { yield* Effect.promise(() => firstQuery) yield* TestClock.adjust("2 minutes") assert.deepInclude(yield* Fiber.join(fiber), { polled: 1, failures: 1 }) - // Two queries, each tried twice at 20 seconds, pass the poll's minute; the rest wait. - assert.lengthOf(calls.monitoring, 4) + // A few queries time out and pass the poll's minute; the rest are never sent. + assert.isBelow(calls.monitoring.length, METRIC_COUNT) const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual(state.last_metrics_error, "Cloud Monitoring request timed out") assert.isNull(state.metrics_watermark_at) @@ -854,23 +877,32 @@ describe("GcpMetricsService", () => { ) }) - it.effect("holds an organization that is over its plan limit for an hour", () => { + it.effect("holds every connector of an organization that is over its plan limit for an hour", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() + const paused = "Metrics are paused: this organization is over its plan limit." return run( testDb, stubFetch(calls, { monitoring: onlyRequestCount, ingestStatus: 402 }), Effect.gen(function* () { yield* insertConnector(testDb, CONNECTOR_A) + // A second connector of the organization that another tick still holds for a minute. + yield* insertConnector(testDb, CONNECTOR_B, { projectId: "acme-staging" }) + yield* Effect.promise(() => + executeSql(testDb, "UPDATE gcp_connectors SET metrics_lease_until = $1 WHERE id = $2", [ + new Date(now + minute).toISOString(), + CONNECTOR_B, + ]), + ) assert.deepInclude(yield* pollAll, { polled: 1, failures: 1, rowsIngested: 0 }) const state = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual( - state.last_metrics_error, - "Metrics are paused: this organization is over its plan limit.", - ) + assert.strictEqual(state.last_metrics_error, paused) assert.isNull(state.metrics_watermark_at) - assert.strictEqual(state.metrics_lease_until?.getTime(), now + 60 * minute) + // Neither is read again before the hour is over. + const sibling = yield* pollState(testDb, CONNECTOR_B) + assert.strictEqual(sibling.last_metrics_error, paused) + assert.strictEqual(sibling.metrics_lease_until?.getTime(), now + 60 * minute) yield* TestClock.setTime(now + 30 * minute) assert.deepInclude(yield* pollAll, { polled: 0, calls: 0 }) }), @@ -986,11 +1018,6 @@ describe("GcpMetricsService resource inventory", () => { quotaProject: "acme-prod", }) assert.deepStrictEqual(calls.assets[0]!.params.getAll("assetTypes"), [...GCP_ASSET_TYPES]) - assert.strictEqual(calls.assets[0]!.params.get("pageSize"), "500") - assert.strictEqual( - calls.assets[0]!.params.get("readMask"), - "name,assetType,displayName,location,state,labels,createTime,updateTime,additionalAttributes", - ) assert.deepStrictEqual(yield* inventory(testDb, CONNECTOR_A), [ ["acme-prod", "cloudresourcemanager.googleapis.com/Project", "acme-prod"], ["acme-prod", "run.googleapis.com/Service", "cart"], diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index e3e9172a97..c91f270168 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -1,20 +1,13 @@ /** - * Google Cloud metrics and resource poller. On each alerting tick it reads the curated Cloud - * Monitoring metrics (`@maple/domain/gcp-metrics`) of every connector that has metrics enabled - * and ships them to the ingest gateway as OTLP, so routing, metering and durability are the - * gateway's. Hourly it also syncs the connector's resource inventory into `gcp_resources`. + * Google Cloud metrics and resource poller. Each alerting tick reads the curated Cloud Monitoring + * metrics (`@maple/domain/gcp-metrics`) of every connector with metrics enabled, at the + * connector's scope (project, folder or organization), and ships them to the ingest gateway as + * OTLP. Hourly it also syncs the scope's resource inventory into `gcp_resources`. * - * A connector covers one scope: a project, a folder or an organization. Both APIs are queried at - * that scope, so a poll costs about one call per curated metric however many projects it holds. - * Maple signs in as its own service account once per tick, then mints a short-lived token for - * each connector's reader account (the one the setup script created). - * - * Work per tick is bounded: connectors are taken least recently polled first, at most - * {@link MAX_CONNECTORS_PER_ORG_TICK} per organization, until a budget runs out. One that waits - * covers a longer window next time. A poll that reaches its own budget keeps what it read and - * moves on, and a metric query that fails twice is skipped for that window. The watermark stays - * only when nothing was shipped (no access, out of quota, nothing readable) or storing failed, so - * a window is ingested twice only if a poll dies between a flush and the watermark write. + * Work per tick is bounded: connectors are taken least recently polled first, a few per + * organization, until the tick's call or time budget runs out. A window is read again only when + * nothing of it was ingested; the exceptions are an ingest failure and the hard timeout, which + * repeat what a poll had already flushed. */ import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" import { IntegrationsPersistenceError, UserId } from "@maple/domain/http" @@ -36,6 +29,7 @@ import { importServiceAccountKey, impersonateReader, listTimeSeriesPage, + RESOURCE_PAGE_SIZE, searchResourcesPage, type GcpReader, type GcpTimeSeriesQuery, @@ -43,13 +37,14 @@ import { import { mapGcpResource, mapGcpTimeSeries, type GcpMetricRows } from "./gcp/mapping" const MINUTE_MS = 60_000 +const TICK_MS = 5 * MINUTE_MS /** The curated metrics document up to 240 s between a sample and its visibility. */ const INGESTION_LAG_MS = 5 * MINUTE_MS -/** How far back the first poll starts, and the most one poll catches up. */ +/** The oldest data a poll reads: where the first one starts, and one after a long pause. */ const MAX_WINDOW_MS = 60 * MINUTE_MS /** - * A tick must finish inside its lease. No connector starts after the tick budget, a poll stops - * reading at its own budget and is cut off at the timeout, so the last one ends 3.5 minutes in. + * A tick must finish inside its lease: no connector starts after the tick budget, a poll stops + * reading at its own, and the timeout cuts off one that stalls 3.5 minutes in at the latest. */ const LEASE_MS = 4 * MINUTE_MS const TICK_BUDGET_MS = 1.5 * MINUTE_MS @@ -57,12 +52,13 @@ const POLL_BUDGET_MS = MINUTE_MS const CONNECTOR_TIMEOUT = Duration.minutes(2) const INGEST_TIMEOUT = Duration.seconds(30) const RESOURCE_SYNC_TIMEOUT = Duration.seconds(30) -/** The alerting Worker gets 10,000 subrequests per invocation, shared with the other ticks. */ +/** + * Outbound requests per tick. A Workers Paid invocation gets 10,000 subrequests unless + * `limits.subrequests` says otherwise, and the other five-minute ticks run in the same one. + */ const MAX_CALLS_PER_TICK = 3_000 -/** Read caps, sized so a tick's decoding and re-encoding stays a few CPU seconds of the cron's 30. */ export const MAX_PAGES_PER_METRIC = 10 -const MAX_POINTS_PER_POLL = 100_000 -const MAX_POINTS_PER_TICK = 300_000 +export const MAX_RESOURCE_PAGES = 20 const MAX_CONNECTORS_PER_ORG_TICK = 10 const CONNECTOR_CONCURRENCY = 3 /** Rows buffered per connector before they are shipped. */ @@ -70,8 +66,6 @@ const FLUSH_ROWS = 5_000 /** An organization over its plan limit is not read again for this long. */ const BILLING_HOLD_MS = 60 * MINUTE_MS const RESOURCE_SYNC_INTERVAL_MS = 60 * MINUTE_MS -/** 500 resources per page. */ -export const MAX_RESOURCE_PAGES = 20 const METRICS = GCP_METRIC_GROUPS.flatMap((group) => group.metrics.map((metric) => ({ group, metric }))) @@ -93,23 +87,23 @@ class GcpMetricsIngestError extends Schema.TaggedError()( { message: Schema.String, status: Schema.optionalKey(Schema.Number) }, ) {} -/** Next window for a connector, on minute boundaries, or null when it is caught up. */ +/** + * The connector's next window, on minute boundaries, or null when it is caught up. It never + * starts more than an hour back: after a long pause the gap is skipped, not replayed. + */ export const nextWindow = (watermarkAt: Date | null, now: number) => { - const horizonMs = now - INGESTION_LAG_MS - ((now - INGESTION_LAG_MS) % MINUTE_MS) - const startMs = watermarkAt === null ? horizonMs - MAX_WINDOW_MS : dateToMs(watermarkAt) - // Cap the end, not the start: a long gap catches up one window per poll instead of being skipped. - const endMs = Math.min(horizonMs, startMs + MAX_WINDOW_MS) + const endMs = now - INGESTION_LAG_MS - ((now - INGESTION_LAG_MS) % MINUTE_MS) + const startMs = Math.max(dateToMs(watermarkAt) ?? 0, endMs - MAX_WINDOW_MS) return startMs < endMs ? { startMs, endMs } : null } export interface GcpMetricsPollSummary { - /** Connectors polled this tick. */ readonly polled: number /** Due connectors left for a later tick: over a cap or budget, or claimed by an overlapping tick. */ readonly deferred: number readonly rowsIngested: number readonly failures: number - /** Metric queries cut short or left unread at a per-poll budget. */ + /** Metric queries cut short or left unread at a budget. */ readonly incompleteMetrics: number readonly calls: number } @@ -125,10 +119,10 @@ const IDLE: GcpMetricsPollSummary = { interface Tick { readonly now: number - /** Counts every outbound request into `budget`. */ readonly httpClient: HttpClient.HttpClient readonly mapleToken: Redacted.Redacted - readonly budget: { calls: number; points: number } + /** Every outbound request of the tick, counted by `httpClient`. */ + readonly budget: { calls: number } } interface ConnectorOutcome { @@ -137,6 +131,9 @@ interface ConnectorOutcome { readonly incompleteMetrics: number } +const NOTHING: ConnectorOutcome = { rowsIngested: 0, failed: false, incompleteMetrics: 0 } +const FAILED: ConnectorOutcome = { ...NOTHING, failed: true } + export interface GcpMetricsServiceApi { /** No-op unless `MAPLE_GCP_SERVICE_ACCOUNT_KEY` is set. */ readonly pollAll: () => Effect.Effect @@ -158,12 +155,14 @@ export class GcpMetricsService extends Context.Service, ) => dbExecute((db) => db.update(gcpConnectors).set(set).where(eq(gcpConnectors.id, id))) - /** A failed poll leaves the watermark alone and says why on the connector. */ + /** + * A failed poll leaves the watermark alone and says why. With `holdUntilMs` the whole + * organization is held, so its other connectors are not read for nothing. + */ const recordFailure = Effect.fnUntraced(function* ( connector: GcpConnectorRow, - now: number, message: string, - holdMs?: number, + holdUntilMs?: number, ) { yield* Effect.annotateCurrentSpan({ "maple.gcp.poll_failed": true }) yield* Effect.logWarning("gcp connector poll failed", { @@ -171,11 +170,23 @@ export class GcpMetricsService extends Context.Service + holdUntilMs === undefined + ? db + .update(gcpConnectors) + .set({ lastMetricsError: message }) + .where(eq(gcpConnectors.id, connector.id)) + : db + .update(gcpConnectors) + .set({ lastMetricsError: message, metricsLeaseUntil: msToDate(holdUntilMs) }) + .where( + and( + eq(gcpConnectors.orgId, connector.orgId), + eq(gcpConnectors.metricsEnabled, true), + ), + ), + ) + return FAILED }) const emitMetrics = Effect.fnUntraced( @@ -277,13 +288,13 @@ export class GcpMetricsService extends Context.Service= RESOURCE_SYNC_INTERVAL_MS - if (window === null && !resourcesDue) return idle - const startedAt = yield* Clock.currentTimeMillis - const outOfTime = Effect.map( - Clock.currentTimeMillis, - (time) => time - startedAt >= POLL_BUDGET_MS, - ) - const reader = yield* impersonateReader(tick.httpClient, tick.mapleToken, connector) - - if (resourcesDue) { + ) { yield* updateConnector( connector.id, yield* resourceSyncOutcome(connector, tick, scope, reader), ) } - if (window === null) return idle const ingestKey = (yield* ingestKeys.getOrCreate(connector.orgId, SYSTEM_USER_ID)) .publicKey + // The metrics get a full budget, whatever sign-in and the inventory sync took. + const startedAt = yield* Clock.currentTimeMillis + const outOfBudget = Effect.map( + Clock.currentTimeMillis, + (time) => + time - startedAt >= POLL_BUDGET_MS || tick.budget.calls >= MAX_CALLS_PER_TICK, + ) + let buffer: GcpMetricRows = { sumRows: [], gaugeRows: [] } let rowsIngested = 0 - let points = 0 /** One metric, page by page; false when pages were left unread. */ const readMetric = Effect.fnUntraced(function* (query: GcpTimeSeriesQuery) { let pageToken: string | undefined for (let page = 0; page < MAX_PAGES_PER_METRIC; page++) { - if (page > 0 && (yield* outOfTime)) return false + if (page > 0 && (yield* outOfBudget)) return false const body = yield* listTimeSeriesPage( tick.httpClient, reader, @@ -361,11 +368,7 @@ export class GcpMetricsService extends Context.Service error.kind === "upstream" }), ) - const series = body.timeSeries ?? [] - const read = series.reduce((sum, item) => sum + (item.points?.length ?? 0), 0) - points += read - tick.budget.points += read - const rows = mapGcpTimeSeries(query, series) + const rows = mapGcpTimeSeries(query, body.timeSeries ?? []) buffer.sumRows.push(...rows.sumRows) buffer.gaugeRows.push(...rows.gaugeRows) if (buffer.sumRows.length + buffer.gaugeRows.length >= FLUSH_ROWS) { @@ -378,13 +381,16 @@ export class GcpMetricsService extends Context.Service= MAX_POINTS_PER_POLL || (yield* outOfTime)) { + // A different metric goes first each tick, so a scope that always runs out of + // budget does not lose the same ones every time. + const first = Math.floor(window.endMs / TICK_MS) % METRICS.length + for (const { group, metric } of [...METRICS.slice(first), ...METRICS.slice(0, first)]) { + if (yield* outOfBudget) { skipped += 1 continue } @@ -393,29 +399,20 @@ export class GcpMetricsService extends Context.Service 0) { - yield* Effect.logWarning("gcp connector poll reached a read budget", { - orgId: connector.orgId, - connectorId: connector.id, - incompleteMetrics: incomplete, - points, - }) - } yield* updateConnector(connector.id, { metricsWatermarkAt: msToDate(window.endMs), lastMetricsReceivedAt: msToDate(tick.now), @@ -448,7 +437,6 @@ export class GcpMetricsService extends Context.Service recordFailure( connector, - tick.now, "Reading the metrics took too long. Maple retries on the next poll.", ), }), Effect.catchTags({ "@maple/api/integrations/GcpApiError": (error) => - recordFailure(connector, tick.now, describeApiError(error)), + recordFailure(connector, describeApiError(error)), "@maple/api/integrations/GcpMetricsIngestError": (error) => error.status === 402 ? recordFailure( connector, - tick.now, "Metrics are paused: this organization is over its plan limit.", - BILLING_HOLD_MS, + tick.now + BILLING_HOLD_MS, ) - : recordFailure(connector, tick.now, error.message), + : recordFailure(connector, error.message), }), ), ) @@ -498,7 +484,6 @@ export class GcpMetricsService extends Context.Service db .select({ id: gcpConnectors.id, orgId: gcpConnectors.orgId }) @@ -517,7 +502,7 @@ export class GcpMetricsService extends Context.Service Effect.sync(() => { @@ -539,7 +524,6 @@ export class GcpMetricsService extends Context.Service= MAX_CALLS_PER_TICK || - budget.points >= MAX_POINTS_PER_TICK || (yield* Clock.currentTimeMillis) - now >= TICK_BUDGET_MS ) { return null @@ -561,13 +545,7 @@ export class GcpMetricsService extends Context.Service({ - rowsIngested: 0, - failed: true, - incompleteMetrics: 0, - }), - ), + }).pipe(Effect.as(FAILED)), ), ), { concurrency: CONNECTOR_CONCURRENCY }, diff --git a/packages/backend/src/services/integrations/gcp/api.ts b/packages/backend/src/services/integrations/gcp/api.ts index acd76276fa..fb2756b84d 100644 --- a/packages/backend/src/services/integrations/gcp/api.ts +++ b/packages/backend/src/services/integrations/gcp/api.ts @@ -1,18 +1,17 @@ // BOUNDARY: This module owns unparsed external values and narrows them before domain use. /** - * The Google calls the metrics poller makes, over WebCrypto and `fetch` so they run in a Worker: - * sign in as Maple's service account, mint a short-lived token for the customer's reader - * account, and read Cloud Monitoring and Cloud Asset Inventory with it. - * - * Nothing here may leak a credential. Tokens stay `Redacted`, and a failure is described by the - * HTTP status and Google's error code only: never the response body, the transport error (it - * holds the request and its Authorization header) or a schema issue (it quotes its input). - * - * Untraced on purpose: the HTTP client already records a span per request, and a scope that is - * not set up yet is an expected state, not an exception on Maple's own traces. + * The Google calls the poller makes, over WebCrypto and `fetch` so they run in a Worker. Nothing + * here may leak a credential: tokens stay `Redacted`, and a failure carries the HTTP status and + * Google's error code only, never a response body, transport error or schema issue. Untraced on + * purpose: the HTTP client records a span per request, and "not set up yet" is not an exception. */ import { gcpConnectorResourceNames } from "@maple/domain/gcp" -import { GCP_ASSET_TYPES, type GcpMetric, type GcpMetricGroup } from "@maple/domain/gcp-metrics" +import { + GCP_ASSET_TYPES, + gcpMetricAligner, + type GcpMetric, + type GcpMetricGroup, +} from "@maple/domain/gcp-metrics" import type { GcpConnectorId, GcpProjectId } from "@maple/domain/primitives" import { Duration, Effect, Option, Redacted, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/http" @@ -22,7 +21,6 @@ const IAM_SCOPE = "https://www.googleapis.com/auth/iam" // Cloud Asset Inventory accepts no narrower scope. What the token can do is bounded by the // reader account's roles, which the setup script keeps to read-only viewers. const READER_SCOPE = "https://www.googleapis.com/auth/cloud-platform" -/** Lifetime of both tokens. A tick is done long before. */ const TOKEN_LIFETIME_SECONDS = 600 const REQUEST_TIMEOUT = Duration.seconds(20) /** @@ -30,8 +28,7 @@ const REQUEST_TIMEOUT = Duration.seconds(20) * connectors decoding a page each stay far below the Worker's 128 MB. */ const TIME_SERIES_PAGE_SIZE = 2_000 -/** Cloud Asset Inventory's maximum. */ -const RESOURCE_PAGE_SIZE = 500 +export const RESOURCE_PAGE_SIZE = 500 export class GcpApiError extends Schema.TaggedError()("@maple/api/integrations/GcpApiError", { message: Schema.String, @@ -168,12 +165,8 @@ export const fetchMapleAccessToken = Effect.fnUntraced(function* ( ] .map((part) => base64Url(JSON.stringify(part))) .join(".") - const signature = yield* Effect.tryPromise(() => + const signature = yield* Effect.promise(() => crypto.subtle.sign("RSASSA-PKCS1-v1_5", signer.key, new TextEncoder().encode(signingInput)), - ).pipe( - Effect.mapError( - () => new GcpApiError({ message: "Signing the Google OAuth assertion failed", kind: "invalid" }), - ), ) const response = yield* call( httpClient, @@ -317,7 +310,7 @@ export const listTimeSeriesPage = ( "interval.startTime": new Date(query.startMs).toISOString(), "interval.endTime": new Date(query.endMs).toISOString(), "aggregation.alignmentPeriod": "60s", - "aggregation.perSeriesAligner": query.metric.aligner, + "aggregation.perSeriesAligner": gcpMetricAligner(query.metric), "aggregation.crossSeriesReducer": query.metric.reducer, "aggregation.groupByFields": [ "resource.label.project_id", @@ -356,7 +349,6 @@ const decodeResourcePage = Schema.decodeUnknownEffect( }), ) -/** One page of the curated asset types in the scope. */ export const searchResourcesPage = ( httpClient: HttpClient.HttpClient, reader: GcpReader, diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index b6910a51b9..2328aa211a 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -28,13 +28,6 @@ export interface GcpMetricRows { readonly gaugeRows: Array } -const cloudPlatform = (resourceType: string): string | undefined => { - if (resourceType === "cloud_run_revision") return "gcp_cloud_run" - if (resourceType === "cloud_function") return "gcp_cloud_functions" - if (resourceType === "gce_instance") return "gcp_compute_engine" - return resourceType.startsWith("k8s_") ? "gcp_kubernetes_engine" : undefined -} - /** `labels` are the series' resource labels: the project plus the group's `resourceLabels`. */ export const gcpResourceAttributes = ( resourceType: string, @@ -46,7 +39,6 @@ export const gcpResourceAttributes = ( if (value) attributes[key] = value } set("cloud.account.id", labels.project_id) - set("cloud.platform", cloudPlatform(resourceType)) // `location` names a zone for zonal resources and a region otherwise. const location = labels.zone ?? labels.location ?? labels.region @@ -57,19 +49,24 @@ export const gcpResourceAttributes = ( let workload: string | undefined if (resourceType === "cloud_run_revision") { workload = labels.service_name + set("cloud.platform", "gcp_cloud_run") set("faas.name", workload) } else if (resourceType === "cloud_function") { workload = labels.function_name + set("cloud.platform", "gcp_cloud_functions") set("faas.name", workload) } else if (resourceType === "k8s_container") { workload = labels.container_name + set("cloud.platform", "gcp_kubernetes_engine") set("k8s.cluster.name", labels.cluster_name) set("k8s.namespace.name", labels.namespace_name) set("k8s.container.name", workload) } else if (resourceType === "k8s_node") { + set("cloud.platform", "gcp_kubernetes_engine") set("k8s.cluster.name", labels.cluster_name) } else if (resourceType === "gce_instance") { workload = metricLabels.instance_name + set("cloud.platform", "gcp_compute_engine") set("host.id", labels.instance_id) set("host.name", workload) } diff --git a/packages/db/src/schema/gcp-connectors.ts b/packages/db/src/schema/gcp-connectors.ts index 50c8863b5c..511eeb4d0a 100644 --- a/packages/db/src/schema/gcp-connectors.ts +++ b/packages/db/src/schema/gcp-connectors.ts @@ -29,7 +29,8 @@ export const gcpConnectors = pgTable( // A tick claims the connector by moving this past now, and it is never cleared: it also // orders the next tick, least recently polled first. metricsLeaseUntil: timestamp("metrics_lease_until", { withTimezone: true, mode: "date" }), - // Last complete sync of `gcp_resources`, and why the latest attempt fell short. + // Last sync of `gcp_resources` that Google answered, whole or cut short at a cap; the next + // one is due an hour later. The error says why the latest attempt fell short. resourcesSyncedAt: timestamp("resources_synced_at", { withTimezone: true, mode: "date" }), lastResourcesError: text("last_resources_error"), createdBy: text("created_by").notNull(), diff --git a/packages/db/src/schema/gcp-resources.ts b/packages/db/src/schema/gcp-resources.ts index 982be70297..70c2857941 100644 --- a/packages/db/src/schema/gcp-resources.ts +++ b/packages/db/src/schema/gcp-resources.ts @@ -5,8 +5,9 @@ import { gcpConnectors } from "./gcp-connectors" /** * What a Google Cloud connector's scope contains, synced from Cloud Asset Inventory: its * projects (that is how a folder or organization scope is discovered) and the resources behind - * the curated metrics. A sync replaces the connector's set, so a resource that is gone upstream - * is gone here; `lastSeenAt` is the sync that last returned the row. + * the curated metrics, by full resource name (`//run.googleapis.com/projects/p/.../services/s`). + * A sync replaces the connector's set, so a resource that is gone upstream is gone here; + * `lastSeenAt` is the sync that last returned the row. */ export const gcpResources = pgTable( "gcp_resources", @@ -16,9 +17,7 @@ export const gcpResources = pgTable( .notNull() .references(() => gcpConnectors.id, { onDelete: "cascade" }), orgId: text("org_id").$type().notNull(), - /** Full resource name, e.g. `//run.googleapis.com/projects/p/locations/l/services/s`. */ name: text("name").notNull(), - /** Cloud Asset Inventory type, e.g. `run.googleapis.com/Service`. */ assetType: text("asset_type").notNull(), projectId: text("project_id").notNull(), location: text("location"), diff --git a/packages/domain/src/gcp-metrics.ts b/packages/domain/src/gcp-metrics.ts index 6f148ea1c3..199cc3fd1d 100644 --- a/packages/domain/src/gcp-metrics.ts +++ b/packages/domain/src/gcp-metrics.ts @@ -13,14 +13,15 @@ export interface GcpMetric { readonly type: string /** Metric name in Maple: the type under a `gcp.` prefix, e.g. `gcp.run.request_count`. */ readonly name: string - readonly unit: string - readonly description: string /** * What Maple stores: `sum` is a delta counter per minute, `gauge` a sampled value, and - * `quantiles` are p50/p95/p99 gauges (attribute `quantile`) computed from a distribution. + * `quantiles` are p50/p95/p99 gauges (attribute `quantile`) of a distribution summed over the + * group. A gauge is aligned by mean, the other two as deltas. */ readonly kind: "sum" | "gauge" | "quantiles" - readonly aligner: "ALIGN_DELTA" | "ALIGN_MEAN" + readonly unit: string + readonly description: string + /** How the series a group folds together are combined. */ readonly reducer: "REDUCE_SUM" | "REDUCE_MEAN" /** Metric labels kept as attributes under their Cloud Monitoring names. */ readonly labels: ReadonlyArray @@ -36,59 +37,32 @@ export interface GcpMetricGroup { readonly metrics: ReadonlyArray } -/** `run.googleapis.com/request_count` -> `gcp.run.request_count`. */ -const mapleName = (type: string): string => { - const [domain = "", ...path] = type.split("/") - return ["gcp", domain.split(".")[0], ...path].join(".") -} +export const gcpMetricAligner = (metric: GcpMetric) => + metric.kind === "gauge" ? "ALIGN_MEAN" : "ALIGN_DELTA" -const counter = ( +const metric = ( + kind: GcpMetric["kind"], type: string, unit: string, description: string, - labels: ReadonlyArray = [], -): GcpMetric => ({ - type, - name: mapleName(type), - unit, - description, - kind: "sum", - aligner: "ALIGN_DELTA", - reducer: "REDUCE_SUM", - labels, - scale: 1, -}) - -const gauge = ( - type: string, - unit: string, - description: string, - reducer: GcpMetric["reducer"], - labels: ReadonlyArray = [], -): GcpMetric => ({ - type, - name: mapleName(type), - unit, - description, - kind: "gauge", - aligner: "ALIGN_MEAN", - reducer, - labels, - scale: 1, -}) + options: Partial> = {}, +): GcpMetric => { + // `run.googleapis.com/request_count` -> `gcp.run.request_count`. + const [domain = "", ...path] = type.split("/") + return { + type, + name: ["gcp", domain.split(".")[0], ...path].join("."), + kind, + unit, + description, + reducer: "REDUCE_SUM", + labels: [], + scale: 1, + ...options, + } +} -/** A distribution, summed across the group so the percentiles cover every instance in it. */ -const quantiles = (type: string, unit: string, description: string, scale = 1): GcpMetric => ({ - type, - name: mapleName(type), - unit, - description, - kind: "quantiles", - aligner: "ALIGN_DELTA", - reducer: "REDUCE_SUM", - labels: [], - scale, -}) +const MEAN = { reducer: "REDUCE_MEAN" } as const /** * Cloud Asset Inventory types kept in the resource inventory: the projects in a connector's @@ -113,43 +87,48 @@ export const GCP_ASSET_TYPES: ReadonlyArray = [ "compute.googleapis.com/ForwardingRule", ] +const RUN = "run.googleapis.com" +const FUNCTIONS = "cloudfunctions.googleapis.com/function" +const GKE = "kubernetes.io" +const GCE = "compute.googleapis.com/instance" +const SQL = "cloudsql.googleapis.com/database" +const PUBSUB = "pubsub.googleapis.com" +const LB = "loadbalancing.googleapis.com/https" + +const INSTANCE = { labels: ["instance_name"] } as const + export const GCP_METRIC_GROUPS: ReadonlyArray = [ { // Cloud Run services, and Cloud Functions (2nd gen), which run on Cloud Run. resourceType: "cloud_run_revision", resourceLabels: ["service_name", "location"], metrics: [ - counter("run.googleapis.com/request_count", "{request}", "Requests that reached the service", [ - "response_code_class", - ]), - quantiles("run.googleapis.com/request_latencies", "ms", "Request latency"), - gauge( - "run.googleapis.com/container/instance_count", - "{instance}", - "Container instances", - "REDUCE_SUM", - ["state"], - ), - quantiles( - "run.googleapis.com/container/cpu/utilizations", + metric("sum", `${RUN}/request_count`, "{request}", "Requests that reached the service", { + labels: ["response_code_class"], + }), + metric("quantiles", `${RUN}/request_latencies`, "ms", "Request latency"), + metric("gauge", `${RUN}/container/instance_count`, "{instance}", "Container instances", { + labels: ["state"], + }), + metric( + "quantiles", + `${RUN}/container/cpu/utilizations`, "1", "CPU utilization across container instances", ), - quantiles( - "run.googleapis.com/container/memory/utilizations", + metric( + "quantiles", + `${RUN}/container/memory/utilizations`, "1", "Memory utilization across container instances", ), - quantiles( - "run.googleapis.com/container/max_request_concurrencies", + metric( + "quantiles", + `${RUN}/container/max_request_concurrencies`, "{request}", "Peak concurrent requests per container instance", ), - counter( - "run.googleapis.com/container/billable_instance_time", - "s", - "Billable container instance time", - ), + metric("sum", `${RUN}/container/billable_instance_time`, "s", "Billable container instance time"), ], }, { @@ -157,36 +136,18 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "cloud_function", resourceLabels: ["function_name", "region"], metrics: [ - counter( - "cloudfunctions.googleapis.com/function/execution_count", - "{execution}", - "Function executions", - ["status"], - ), + metric("sum", `${FUNCTIONS}/execution_count`, "{execution}", "Function executions", { + labels: ["status"], + }), // Reported in nanoseconds. - quantiles( - "cloudfunctions.googleapis.com/function/execution_times", - "ms", - "Function execution time", - 1e-6, - ), - gauge( - "cloudfunctions.googleapis.com/function/instance_count", - "{instance}", - "Function instances", - "REDUCE_SUM", - ["state"], - ), - quantiles( - "cloudfunctions.googleapis.com/function/user_memory_bytes", - "By", - "Peak memory used per execution", - ), - counter( - "cloudfunctions.googleapis.com/function/network_egress", - "By", - "Outgoing network traffic", - ), + metric("quantiles", `${FUNCTIONS}/execution_times`, "ms", "Function execution time", { + scale: 1e-6, + }), + metric("gauge", `${FUNCTIONS}/instance_count`, "{instance}", "Function instances", { + labels: ["state"], + }), + metric("quantiles", `${FUNCTIONS}/user_memory_bytes`, "By", "Peak memory used per execution"), + metric("sum", `${FUNCTIONS}/network_egress`, "By", "Outgoing network traffic"), ], }, { @@ -194,24 +155,25 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "k8s_container", resourceLabels: ["cluster_name", "location", "namespace_name", "container_name"], metrics: [ - counter("kubernetes.io/container/cpu/core_usage_time", "s", "CPU time used"), - gauge( - "kubernetes.io/container/cpu/limit_utilization", + metric("sum", `${GKE}/container/cpu/core_usage_time`, "s", "CPU time used"), + metric( + "gauge", + `${GKE}/container/cpu/limit_utilization`, "1", "Fraction of the CPU limit in use, averaged over pods", - "REDUCE_MEAN", - ), - gauge("kubernetes.io/container/memory/used_bytes", "By", "Memory in use", "REDUCE_SUM", [ - "memory_type", - ]), - gauge( - "kubernetes.io/container/memory/limit_utilization", + MEAN, + ), + metric("gauge", `${GKE}/container/memory/used_bytes`, "By", "Memory in use", { + labels: ["memory_type"], + }), + metric( + "gauge", + `${GKE}/container/memory/limit_utilization`, "1", "Fraction of the memory limit in use, averaged over pods", - "REDUCE_MEAN", - ["memory_type"], + { ...MEAN, labels: ["memory_type"] }, ), - counter("kubernetes.io/container/restart_count", "{restart}", "Container restarts"), + metric("sum", `${GKE}/container/restart_count`, "{restart}", "Container restarts"), ], }, { @@ -219,18 +181,19 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "k8s_node", resourceLabels: ["cluster_name", "location"], metrics: [ - gauge( - "kubernetes.io/node/cpu/allocatable_utilization", + metric( + "gauge", + `${GKE}/node/cpu/allocatable_utilization`, "1", "Fraction of allocatable CPU in use, averaged over nodes", - "REDUCE_MEAN", + MEAN, ), - gauge( - "kubernetes.io/node/memory/allocatable_utilization", + metric( + "gauge", + `${GKE}/node/memory/allocatable_utilization`, "1", "Fraction of allocatable memory in use, averaged over nodes", - "REDUCE_MEAN", - ["memory_type"], + { ...MEAN, labels: ["memory_type"] }, ), ], }, @@ -239,86 +202,37 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "gce_instance", resourceLabels: ["instance_id", "zone"], metrics: [ - gauge( - "compute.googleapis.com/instance/cpu/utilization", - "1", - "Fraction of the allocated CPU in use", - "REDUCE_MEAN", - ["instance_name"], - ), + metric("gauge", `${GCE}/cpu/utilization`, "1", "Fraction of the allocated CPU in use", { + ...MEAN, + ...INSTANCE, + }), // E2 machine types only. - gauge( - "compute.googleapis.com/instance/memory/balloon/ram_used", - "By", - "Memory in use", - "REDUCE_SUM", - ["instance_name"], - ), - counter("compute.googleapis.com/instance/network/received_bytes_count", "By", "Bytes received", [ - "instance_name", - ]), - counter("compute.googleapis.com/instance/network/sent_bytes_count", "By", "Bytes sent", [ - "instance_name", - ]), - counter("compute.googleapis.com/instance/disk/read_bytes_count", "By", "Bytes read from disk", [ - "instance_name", - ]), - counter("compute.googleapis.com/instance/disk/write_bytes_count", "By", "Bytes written to disk", [ - "instance_name", - ]), + metric("gauge", `${GCE}/memory/balloon/ram_used`, "By", "Memory in use", INSTANCE), + metric("sum", `${GCE}/network/received_bytes_count`, "By", "Bytes received", INSTANCE), + metric("sum", `${GCE}/network/sent_bytes_count`, "By", "Bytes sent", INSTANCE), + metric("sum", `${GCE}/disk/read_bytes_count`, "By", "Bytes read from disk", INSTANCE), + metric("sum", `${GCE}/disk/write_bytes_count`, "By", "Bytes written to disk", INSTANCE), ], }, { resourceType: "cloudsql_database", resourceLabels: ["database_id", "region"], metrics: [ - gauge( - "cloudsql.googleapis.com/database/cpu/utilization", - "1", - "Fraction of the reserved CPU in use", - "REDUCE_MEAN", - ), - gauge( - "cloudsql.googleapis.com/database/memory/utilization", - "1", - "Fraction of the memory quota in use", - "REDUCE_MEAN", - ), - gauge( - "cloudsql.googleapis.com/database/disk/utilization", - "1", - "Fraction of the disk quota in use", - "REDUCE_MEAN", - ), + metric("gauge", `${SQL}/cpu/utilization`, "1", "Fraction of the reserved CPU in use", MEAN), + metric("gauge", `${SQL}/memory/utilization`, "1", "Fraction of the memory quota in use", MEAN), + metric("gauge", `${SQL}/disk/utilization`, "1", "Fraction of the disk quota in use", MEAN), // MySQL and SQL Server. - gauge( - "cloudsql.googleapis.com/database/network/connections", - "{connection}", - "Connections to the instance", - "REDUCE_SUM", - ), + metric("gauge", `${SQL}/network/connections`, "{connection}", "Connections to the instance"), // PostgreSQL, summed over databases. - gauge( - "cloudsql.googleapis.com/database/postgresql/num_backends", - "{connection}", - "Connections to the instance", - "REDUCE_SUM", - ), - counter( - "cloudsql.googleapis.com/database/disk/read_ops_count", - "{operation}", - "Disk read operations", - ), - counter( - "cloudsql.googleapis.com/database/disk/write_ops_count", - "{operation}", - "Disk write operations", - ), - gauge( - "cloudsql.googleapis.com/database/replication/replica_lag", + metric("gauge", `${SQL}/postgresql/num_backends`, "{connection}", "Connections to the instance"), + metric("sum", `${SQL}/disk/read_ops_count`, "{operation}", "Disk read operations"), + metric("sum", `${SQL}/disk/write_ops_count`, "{operation}", "Disk write operations"), + metric( + "gauge", + `${SQL}/replication/replica_lag`, "s", "How far a read replica is behind its primary", - "REDUCE_MEAN", + MEAN, ), ], }, @@ -326,36 +240,36 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "pubsub_subscription", resourceLabels: ["subscription_id"], metrics: [ - gauge( - "pubsub.googleapis.com/subscription/num_undelivered_messages", + metric( + "gauge", + `${PUBSUB}/subscription/num_undelivered_messages`, "{message}", "Unacknowledged messages", - "REDUCE_SUM", ), - gauge( - "pubsub.googleapis.com/subscription/oldest_unacked_message_age", + metric( + "gauge", + `${PUBSUB}/subscription/oldest_unacked_message_age`, "s", "Age of the oldest unacknowledged message", - "REDUCE_MEAN", + MEAN, ), - counter( - "pubsub.googleapis.com/subscription/sent_message_count", + metric( + "sum", + `${PUBSUB}/subscription/sent_message_count`, "{message}", "Messages sent to subscribers", ), - counter( - "pubsub.googleapis.com/subscription/ack_message_count", - "{message}", - "Messages acknowledged", - ), - counter( - "pubsub.googleapis.com/subscription/push_request_count", + metric("sum", `${PUBSUB}/subscription/ack_message_count`, "{message}", "Messages acknowledged"), + metric( + "sum", + `${PUBSUB}/subscription/push_request_count`, "{request}", "Push delivery attempts", - ["response_class"], + { labels: ["response_class"] }, ), - counter( - "pubsub.googleapis.com/subscription/dead_letter_message_count", + metric( + "sum", + `${PUBSUB}/subscription/dead_letter_message_count`, "{message}", "Messages moved to the dead-letter topic", ), @@ -365,9 +279,9 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "pubsub_topic", resourceLabels: ["topic_id"], metrics: [ - counter("pubsub.googleapis.com/topic/send_request_count", "{request}", "Publish requests", [ - "response_class", - ]), + metric("sum", `${PUBSUB}/topic/send_request_count`, "{request}", "Publish requests", { + labels: ["response_class"], + }), ], }, { @@ -375,35 +289,26 @@ export const GCP_METRIC_GROUPS: ReadonlyArray = [ resourceType: "https_lb_rule", resourceLabels: ["url_map_name", "backend_target_name"], metrics: [ - counter("loadbalancing.googleapis.com/https/request_count", "{request}", "Requests served", [ - "response_code_class", - ]), - quantiles( - "loadbalancing.googleapis.com/https/total_latencies", + metric("sum", `${LB}/request_count`, "{request}", "Requests served", { + labels: ["response_code_class"], + }), + metric( + "quantiles", + `${LB}/total_latencies`, "ms", "Latency from the request reaching the proxy to the last response byte", ), - quantiles( - "loadbalancing.googleapis.com/https/backend_latencies", + metric( + "quantiles", + `${LB}/backend_latencies`, "ms", "Latency from the proxy sending the request to the backend to the last response byte", ), - counter( - "loadbalancing.googleapis.com/https/backend_request_count", - "{request}", - "Requests sent to backends", - ["response_code_class"], - ), - counter( - "loadbalancing.googleapis.com/https/request_bytes_count", - "By", - "Request bytes received from clients", - ), - counter( - "loadbalancing.googleapis.com/https/response_bytes_count", - "By", - "Response bytes sent to clients", - ), + metric("sum", `${LB}/backend_request_count`, "{request}", "Requests sent to backends", { + labels: ["response_code_class"], + }), + metric("sum", `${LB}/request_bytes_count`, "By", "Request bytes received from clients"), + metric("sum", `${LB}/response_bytes_count`, "By", "Response bytes sent to clients"), ], }, ] From c3a79cf962e89c960e6ece459c0f014f4cc1d244 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 8 Oct 2026 21:29:18 +0200 Subject: [PATCH 04/17] fix(integrations): read Cloud Monitoring point times with timestampMs --- packages/backend/src/services/integrations/gcp/mapping.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index 2328aa211a..913004d35f 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -190,7 +190,7 @@ export const mapGcpTimeSeries = ( exemplars_filtered_attributes: [], }) for (const point of item.points ?? []) { - const endMs = Date.parse(point.interval.endTime) + const endMs = timestampMs(point.interval.endTime) if (!(endMs > query.startMs && endMs <= query.endMs)) continue if (metric.kind === "quantiles") { for (const q of QUANTILES) { From 66cf416e78f59f57e8a9af24dcb8f6b1043141df Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 00:17:06 +0200 Subject: [PATCH 05/17] fix(integrations): show a Pub/Sub resource by its id, not its full path Cloud Asset Inventory answers projects/

/topics/ as the display name of Pub/Sub topics and subscriptions, so the inventory listed them under names that only differ at the end. --- .../src/services/integrations/gcp/mapping.test.ts | 11 +++++++++++ .../backend/src/services/integrations/gcp/mapping.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/gcp/mapping.test.ts b/packages/backend/src/services/integrations/gcp/mapping.test.ts index 5efdf7d9d6..de196c543f 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.test.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.test.ts @@ -257,4 +257,15 @@ describe("mapGcpResource", () => { }), ).toBeUndefined() }) + + it("shortens the path Pub/Sub answers as a display name to the resource's own id", () => { + expect( + mapGcpResource({ + name: "//pubsub.googleapis.com/projects/acme-prod/topics/orders", + assetType: "pubsub.googleapis.com/Topic", + displayName: "projects/acme-prod/topics/orders", + location: "global", + }), + ).toMatchObject({ projectId: "acme-prod", displayName: "orders" }) + }) }) diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index 913004d35f..922266f991 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -126,12 +126,16 @@ export const mapGcpResource = (result: GcpResourceSearchResult) => { const attribute = result.additionalAttributes?.projectId const projectId = Predicate.isString(attribute) ? attribute : /\/projects\/([^/]+)/.exec(result.name)?.[1] if (projectId === undefined) return undefined + // Pub/Sub answers its full path (`projects/

/topics/`) as the display name. + const displayName = result.displayName?.startsWith("projects/") + ? result.displayName.slice(result.displayName.lastIndexOf("/") + 1) + : result.displayName return { name: result.name, assetType: result.assetType, projectId, location: result.location ?? null, - displayName: result.displayName ?? null, + displayName: displayName ?? null, state: result.state ?? null, labels: result.labels ?? {}, resourceCreatedAt: toDate(result.createTime), From f79b7d6414ea46f4b6981cccb6d41858b2021b1e Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 00:17:06 +0200 Subject: [PATCH 06/17] fix(integrations): say so when Google Cloud refuses a read over billing or a disabled API A refusal that carries google.rpc.ErrorInfo keeps its reason next to the status code, and BILLING_DISABLED and SERVICE_DISABLED name the host project instead of pointing at the setup script's grants. The reason is kept only when it reads like one of Google's codes; the answer's text is still never stored. --- .../integrations/GcpMetricsService.test.ts | 110 +++++++++++++++++- .../integrations/GcpMetricsService.ts | 22 +++- .../src/services/integrations/gcp/api.ts | 34 +++++- 3 files changed, 156 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 6dc78c62d4..387cc74383 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -119,12 +119,38 @@ interface StubOptions { const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }) -const googleError = (status: number, code: string) => +const googleError = (status: number, code: string, details?: ReadonlyArray) => json( - { error: { code: status, status: code, message: `details that must not be stored: ${code}` } }, + { + error: { + code: status, + status: code, + message: `details that must not be stored: ${code}`, + ...(details === undefined ? undefined : { details }), + }, + }, status, ) +/** Google's machine-readable cause of a refusal, as `error.details` carries it. */ +const errorInfo = (reason: string) => ({ + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + reason, + domain: "googleapis.com", + metadata: { consumer: "projects/123456789012", service: "monitoring.googleapis.com" }, +}) + +// What Cloud Monitoring answered for a host project without billing on 2026-10-08, with the +// project number replaced: the cause is in the text only. +const NO_BILLING_ANSWER = { + error: { + code: 403, + message: + "This API method requires billing to be enabled. Please enable billing on project #123456789012 by visiting https://console.developers.google.com/billing/enable?project=123456789012 then retry. If you enabled billing for this project recently, wait a few minutes for the action to propagate to our systems and retry.", + status: "PERMISSION_DENIED", + }, +} + /** One Cloud Run series with two points inside the first poll's window. */ const requestCountSeries = (projectId = "acme-prod") => ({ timeSeries: [ @@ -787,6 +813,86 @@ describe("GcpMetricsService", () => { ) }) + it.effect("names missing billing or a disabled API only where Google gives the reason as a code", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + const recorder = makeRecorder() + let monitoring = () => json(NO_BILLING_ANSWER, 403) + let impersonate: () => Response | undefined = () => undefined + const stub = stubFetch(calls, { + impersonate: () => impersonate(), + monitoring: () => monitoring(), + assets: () => + googleError(403, "PERMISSION_DENIED", [ + { "@type": "type.googleapis.com/google.rpc.Help", links: [] }, + errorInfo("SERVICE_DISABLED"), + ]), + }) + const metricsError = pollState(testDb, CONNECTOR_A).pipe( + Effect.map((state) => state.last_metrics_error), + ) + const hint = + "Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply." + const generic = `Cloud Monitoring returned 403 PERMISSION_DENIED. ${hint}` + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + yield* pollAll + assert.strictEqual(yield* metricsError, generic) + assert.strictEqual( + (yield* pollState(testDb, CONNECTOR_A)).last_resources_error, + "Cloud Asset Inventory returned 403 PERMISSION_DENIED (SERVICE_DISABLED). This API is not enabled in the host project acme-prod; run the setup script again.", + ) + + monitoring = () => googleError(403, "PERMISSION_DENIED", [errorInfo("BILLING_DISABLED")]) + yield* TestClock.setTime(now + 5 * minute) + yield* pollAll + assert.strictEqual( + yield* metricsError, + "Cloud Monitoring returned 403 PERMISSION_DENIED (BILLING_DISABLED). This API requires billing to be enabled on the host project acme-prod.", + ) + + // A reason that does not read like one of Google's codes is dropped. + monitoring = () => + googleError(403, "PERMISSION_DENIED", [errorInfo("billing: see example.com")]) + yield* TestClock.setTime(now + 10 * minute) + yield* pollAll + assert.strictEqual(yield* metricsError, generic) + + // Signing in as the reader does not go through the host project. + impersonate = () => googleError(403, "PERMISSION_DENIED", [errorInfo("SERVICE_DISABLED")]) + yield* TestClock.setTime(now + 15 * minute) + yield* pollAll + assert.strictEqual( + yield* metricsError, + `Google IAM returned 403 PERMISSION_DENIED (SERVICE_DISABLED). ${hint}`, + ) + + // Details of another form cost neither the status code nor the poll. + impersonate = () => undefined + monitoring = () => + json({ error: { status: "PERMISSION_DENIED", details: "BILLING_DISABLED" } }, 403) + yield* TestClock.setTime(now + 20 * minute) + yield* pollAll + assert.strictEqual(yield* metricsError, generic) + + const recorded = recorder.text() + assert.include(recorded, "BILLING_DISABLED") + for (const text of [ + "Please enable billing", + "must not be stored", + "example.com", + "123456789012", + ]) { + assert.notInclude(recorded, text) + } + }), + { recorder }, + ) + }) + it.effect("tries a query Google failed once more, then skips it for this window", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index c91f270168..1601f39379 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -11,7 +11,7 @@ */ import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" import { IntegrationsPersistenceError, UserId } from "@maple/domain/http" -import type { OrgId } from "@maple/domain/primitives" +import type { GcpProjectId, OrgId } from "@maple/domain/primitives" import { gcpConnectors, gcpResources, type GcpConnectorRow } from "@maple/db" import { and, asc, eq, isNull, lt, or, sql } from "drizzle-orm" import { Cause, Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" @@ -74,8 +74,18 @@ const SYSTEM_USER_ID = Schema.decodeUnknownSync(UserId)("system-gcp-metrics") const SETUP_HINT = "Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply." -const describeApiError = (error: GcpApiError) => - error.kind === "denied" || error.kind === "not_found" ? `${error.message}. ${SETUP_HINT}` : error.message +/** What the connection shows for a failed call. The host project is the one Google checks. */ +const describeApiError = (error: GcpApiError, hostProject: GcpProjectId) => { + if (error.reason === "BILLING_DISABLED") { + return `${error.message}. This API requires billing to be enabled on the host project ${hostProject}.` + } + if (error.reason === "SERVICE_DISABLED") { + return `${error.message}. This API is not enabled in the host project ${hostProject}; run the setup script again.` + } + return error.kind === "denied" || error.kind === "not_found" + ? `${error.message}. ${SETUP_HINT}` + : error.message +} const toPersistenceError = makePersistenceErrorMapper( IntegrationsPersistenceError, @@ -313,7 +323,9 @@ export class GcpMetricsService extends Context.Service - Effect.succeed({ lastResourcesError: describeApiError(error) }), + Effect.succeed({ + lastResourcesError: describeApiError(error, connector.projectId), + }), "@maple/http/errors/IntegrationsPersistenceError": () => Effect.succeed({ lastResourcesError: "Maple could not store the inventory." }), }), @@ -460,7 +472,7 @@ export class GcpMetricsService extends Context.Service - recordFailure(connector, describeApiError(error)), + recordFailure(connector, describeApiError(error, connector.projectId)), "@maple/api/integrations/GcpMetricsIngestError": (error) => error.status === 402 ? recordFailure( diff --git a/packages/backend/src/services/integrations/gcp/api.ts b/packages/backend/src/services/integrations/gcp/api.ts index fb2756b84d..d2a8c15a14 100644 --- a/packages/backend/src/services/integrations/gcp/api.ts +++ b/packages/backend/src/services/integrations/gcp/api.ts @@ -2,8 +2,9 @@ /** * The Google calls the poller makes, over WebCrypto and `fetch` so they run in a Worker. Nothing * here may leak a credential: tokens stay `Redacted`, and a failure carries the HTTP status and - * Google's error code only, never a response body, transport error or schema issue. Untraced on - * purpose: the HTTP client records a span per request, and "not set up yet" is not an exception. + * Google's error code and reason only, never a response body, transport error or schema issue. + * Untraced on purpose: the HTTP client records a span per request, and "not set up yet" is not an + * exception. */ import { gcpConnectorResourceNames } from "@maple/domain/gcp" import { @@ -37,6 +38,11 @@ export class GcpApiError extends Schema.TaggedError()("@maple/api/i * `upstream`: 5xx, transport, timeout or an undecodable body. */ kind: Schema.Literals(["denied", "not_found", "rate_limited", "invalid", "upstream"]), + /** + * Why Google refused a call made through the host project, where its answer says so: + * `SERVICE_DISABLED`, `BILLING_DISABLED`, ... + */ + reason: Schema.optionalKey(Schema.String), }) {} const errorKind = (status: number): GcpApiError["kind"] => @@ -66,6 +72,23 @@ const errorCode = (body: unknown): string => { return /^[A-Za-z_]{1,40}$/.test(code) ? ` ${code}` : "" } +// A refusal can name its cause in `error.details`, as a `google.rpc.ErrorInfo` whose `reason` is +// UPPER_SNAKE_CASE and 63 characters at most. Nothing else of the details is kept. +const decodeErrorDetails = Schema.decodeUnknownOption( + Schema.Struct({ error: Schema.Struct({ details: Schema.Array(Schema.Unknown) }) }), +) +const decodeErrorInfo = Schema.decodeUnknownOption( + Schema.Struct({ + "@type": Schema.Literal("type.googleapis.com/google.rpc.ErrorInfo"), + reason: Schema.String.check(Schema.isPattern(/^[A-Z][A-Z0-9_]{1,61}[A-Z0-9]$/)), + }), +) + +const errorReason = (body: unknown): string | undefined => + Option.toArray(decodeErrorDetails(body)) + .flatMap((decoded) => decoded.error.details) + .flatMap((detail) => Option.toArray(decodeErrorInfo(detail)))[0]?.reason + /** One JSON call. `api` names the Google service in the error message. */ const call = ( httpClient: HttpClient.HttpClient, @@ -79,9 +102,11 @@ const call = ( const response = yield* httpClient.execute(request) const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)) if (response.status >= 300) { + const reason = errorReason(body) return yield* new GcpApiError({ - message: `${api} returned ${response.status}${errorCode(body)}`, + message: `${api} returned ${response.status}${errorCode(body)}${reason === undefined ? "" : ` (${reason})`}`, kind: errorKind(response.status), + ...(reason === undefined ? undefined : { reason }), }) } return yield* decode(body) @@ -223,6 +248,9 @@ export const impersonateReader = Effect.fnUntraced(function* ( }), ), decodeImpersonatedToken, + ).pipe( + // Not made through the host project, so its reason says nothing about that project. + Effect.mapError(({ message, kind }) => new GcpApiError({ message, kind })), ) return { accessToken: response.accessToken, quotaProject: connector.projectId } satisfies GcpReader }) From 972e103cabdc2ab53cc9dd5e42e98ef57da8213c Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 00:17:53 +0200 Subject: [PATCH 07/17] fix(integrations): wait for the first metrics again when Google Cloud metrics are switched back on The reader account is gone until the setup script is re-run, so the last poll's time and error are cleared. The watermark and the lease stay. --- .../integrations/GcpConnectorService.test.ts | 17 ++++++++++++++++- .../integrations/GcpConnectorService.ts | 4 ++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.test.ts b/packages/backend/src/services/integrations/GcpConnectorService.test.ts index cd9313b4df..b114b39a87 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.test.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.test.ts @@ -292,11 +292,26 @@ describe("GcpConnectorService", () => { // Both were created in the same test-clock instant, so their order is by id. assert.sameDeepMembers([...(yield* gcp.status(orgId)).connectors], [polled, other]) - // Switching a flag leaves the poller's state as it is. + // Switching metrics off leaves the poller's state as it is. assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, { metricsEnabled: false }), { ...polled, metricsEnabled: false, }) + // Back on, the connection waits for its first metrics again; the poller continues + // from its watermark. + assert.deepStrictEqual(yield* gcp.update(orgId, connector.id, { metricsEnabled: true }), { + ...polled, + lastMetricsReceivedAt: null, + lastMetricsError: null, + }) + const kept = yield* Effect.promise(() => + queryFirstRow<{ metrics_watermark_at: Date | null }>( + testDb, + "SELECT metrics_watermark_at FROM gcp_connectors WHERE id = $1", + [connector.id], + ), + ) + assert.strictEqual(kept?.metrics_watermark_at?.getTime(), Date.parse("2026-10-08T09:10:00.000Z")) // The inventory goes with its connector. yield* gcp.delete(orgId, connector.id) diff --git a/packages/backend/src/services/integrations/GcpConnectorService.ts b/packages/backend/src/services/integrations/GcpConnectorService.ts index 7b4d099ef0..910d0f03ef 100644 --- a/packages/backend/src/services/integrations/GcpConnectorService.ts +++ b/packages/backend/src/services/integrations/GcpConnectorService.ts @@ -302,6 +302,10 @@ export class GcpConnectorService extends Context.Service Date: Fri, 9 Oct 2026 02:05:56 +0200 Subject: [PATCH 08/17] feat(integrations): Google Cloud metrics errors that name the cause and what to do Every text the poller stores on a connection is one sentence of cause and one of action, with Google's status at the end. A host project without billing is told apart from a missing role: Google answers it with a bare 403, so its sentence is recognised to choose Maple's own message and is not kept. --- .../integrations/GcpMetricsService.test.ts | 73 +++++++++---------- .../integrations/GcpMetricsService.ts | 71 +++++++++++++----- .../src/services/integrations/gcp/api.ts | 50 ++++++++++--- .../domain/src/http/v2/integrations-gcp.ts | 2 +- 4 files changed, 127 insertions(+), 69 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 387cc74383..f1353b25cb 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -151,6 +151,16 @@ const NO_BILLING_ANSWER = { }, } +// What a connection shows, for the project connector `acme-prod`. +const READ_DENIED = + "Google denied Maple's read of project acme-prod: the read-only roles are missing. Run the setup script again: it grants them. A new grant can take a few minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED)" +const CANNOT_SIGN_IN = + "Maple can't sign in as this connection's read-only service account. It was deleted, or the grant to Maple was removed or blocked by an organization policy. Run the setup script again and read its last lines. (IAM Credentials returned 403 PERMISSION_DENIED)" +const NO_BILLING = + "The host project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED)" +const queriesFailed = (failed: number, type: string, answer: string) => + `${failed} of ${METRIC_COUNT} metric queries failed, first ${type}. The rest were stored. Maple retries in 5 minutes. (Cloud Monitoring returned ${answer})` + /** One Cloud Run series with two points inside the first poll's window. */ const requestCountSeries = (projectId = "acme-prod") => ({ timeSeries: [ @@ -655,10 +665,7 @@ describe("GcpMetricsService", () => { assert.isTrue(calls.monitoring.every((call) => call.scope === "projects/other-prod")) const denied = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual( - denied.last_metrics_error, - "Google IAM returned 403 PERMISSION_DENIED. Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply.", - ) + assert.strictEqual(denied.last_metrics_error, CANNOT_SIGN_IN) assert.isNull(denied.metrics_watermark_at) assert.isNull(denied.last_metrics_received_at) @@ -702,7 +709,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.strictEqual( state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries held more than one poll reads; connect folders or projects separately to collect all of it.`, + `1 of ${METRIC_COUNT} metric queries hold more series than one read takes. Connect the folders or projects separately to collect all of it.`, ) }), ) @@ -784,7 +791,7 @@ describe("GcpMetricsService", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries failed. First: ${rejected}: Cloud Monitoring returned 400 INVALID_ARGUMENT.`, + queriesFailed(1, rejected, "400 INVALID_ARGUMENT"), ) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) }), @@ -804,16 +811,13 @@ describe("GcpMetricsService", () => { assert.lengthOf(calls.monitoring, 1) assert.lengthOf(calls.ingest, 0) const state = yield* pollState(testDb, CONNECTOR_A) - assert.match( - state.last_metrics_error ?? "", - /^Cloud Monitoring returned 403 PERMISSION_DENIED\. Run the setup script/, - ) + assert.strictEqual(state.last_metrics_error, READ_DENIED) assert.isNull(state.metrics_watermark_at) }), ) }) - it.effect("names missing billing or a disabled API only where Google gives the reason as a code", () => { + it.effect("names missing billing or a disabled API, and stores nothing of what Google wrote", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() const recorder = makeRecorder() @@ -831,44 +835,36 @@ describe("GcpMetricsService", () => { const metricsError = pollState(testDb, CONNECTOR_A).pipe( Effect.map((state) => state.last_metrics_error), ) - const hint = - "Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply." - const generic = `Cloud Monitoring returned 403 PERMISSION_DENIED. ${hint}` return run( testDb, stub, Effect.gen(function* () { yield* insertConnector(testDb, CONNECTOR_A) yield* pollAll - assert.strictEqual(yield* metricsError, generic) + // Google's no-billing answer has no code for its cause: its sentence is recognised. + assert.strictEqual(yield* metricsError, NO_BILLING) assert.strictEqual( (yield* pollState(testDb, CONNECTOR_A)).last_resources_error, - "Cloud Asset Inventory returned 403 PERMISSION_DENIED (SERVICE_DISABLED). This API is not enabled in the host project acme-prod; run the setup script again.", + "The Cloud Asset Inventory API is switched off in the host project acme-prod. Run the setup script again: it switches the API on. (Cloud Asset Inventory returned 403 PERMISSION_DENIED)", ) monitoring = () => googleError(403, "PERMISSION_DENIED", [errorInfo("BILLING_DISABLED")]) yield* TestClock.setTime(now + 5 * minute) yield* pollAll - assert.strictEqual( - yield* metricsError, - "Cloud Monitoring returned 403 PERMISSION_DENIED (BILLING_DISABLED). This API requires billing to be enabled on the host project acme-prod.", - ) + assert.strictEqual(yield* metricsError, NO_BILLING) // A reason that does not read like one of Google's codes is dropped. monitoring = () => googleError(403, "PERMISSION_DENIED", [errorInfo("billing: see example.com")]) yield* TestClock.setTime(now + 10 * minute) yield* pollAll - assert.strictEqual(yield* metricsError, generic) + assert.strictEqual(yield* metricsError, READ_DENIED) // Signing in as the reader does not go through the host project. impersonate = () => googleError(403, "PERMISSION_DENIED", [errorInfo("SERVICE_DISABLED")]) yield* TestClock.setTime(now + 15 * minute) yield* pollAll - assert.strictEqual( - yield* metricsError, - `Google IAM returned 403 PERMISSION_DENIED (SERVICE_DISABLED). ${hint}`, - ) + assert.strictEqual(yield* metricsError, CANNOT_SIGN_IN) // Details of another form cost neither the status code nor the poll. impersonate = () => undefined @@ -876,10 +872,10 @@ describe("GcpMetricsService", () => { json({ error: { status: "PERMISSION_DENIED", details: "BILLING_DISABLED" } }, 403) yield* TestClock.setTime(now + 20 * minute) yield* pollAll - assert.strictEqual(yield* metricsError, generic) + assert.strictEqual(yield* metricsError, READ_DENIED) const recorded = recorder.text() - assert.include(recorded, "BILLING_DISABLED") + assert.include(recorded, "has no active billing account") for (const text of [ "Please enable billing", "must not be stored", @@ -914,10 +910,7 @@ describe("GcpMetricsService", () => { assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) assert.lengthOf(calls.monitoring, METRIC_COUNT + 2) const state = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual( - state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries failed. First: ${broken}: Cloud Monitoring returned 503 UNAVAILABLE.`, - ) + assert.strictEqual(state.last_metrics_error, queriesFailed(1, broken, "503 UNAVAILABLE")) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) }), ) @@ -944,7 +937,7 @@ describe("GcpMetricsService", () => { assert.match( state.last_metrics_error ?? "", new RegExp( - `^${METRIC_COUNT - 1} of ${METRIC_COUNT} metric queries failed\\. First: .+: Cloud Monitoring returned 429 RESOURCE_EXHAUSTED\\.$`, + `^${METRIC_COUNT - 1} of ${METRIC_COUNT} metric queries failed, first .+\\. The rest were stored\\. Maple retries in 5 minutes\\. \\(Cloud Monitoring returned 429 RESOURCE_EXHAUSTED\\)$`, ), ) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) @@ -977,7 +970,10 @@ describe("GcpMetricsService", () => { // A few queries time out and pass the poll's minute; the rest are never sent. assert.isBelow(calls.monitoring.length, METRIC_COUNT) const state = yield* pollState(testDb, CONNECTOR_A) - assert.strictEqual(state.last_metrics_error, "Cloud Monitoring request timed out") + assert.strictEqual( + state.last_metrics_error, + "Google's Cloud Monitoring API did not answer Maple's request. Maple retries in 5 minutes. If this repeats, write to support@maple.dev. (Cloud Monitoring request timed out)", + ) assert.isNull(state.metrics_watermark_at) }), ) @@ -986,7 +982,8 @@ describe("GcpMetricsService", () => { it.effect("holds every connector of an organization that is over its plan limit for an hour", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() - const paused = "Metrics are paused: this organization is over its plan limit." + const paused = + "Metrics are paused: this Maple organization is over its plan limit. Maple tries again in an hour. See Settings, Billing." return run( testDb, stubFetch(calls, { monitoring: onlyRequestCount, ingestStatus: 402 }), @@ -1167,9 +1164,9 @@ describe("GcpMetricsService resource inventory", () => { yield* insertConnector(testDb, CONNECTOR_A) assert.deepInclude(yield* pollAll, { polled: 1, failures: 0, rowsIngested: 2 }) const state = yield* pollState(testDb, CONNECTOR_A) - assert.match( - state.last_resources_error ?? "", - /^Cloud Asset Inventory returned 403 PERMISSION_DENIED\. Run the setup script/, + assert.strictEqual( + state.last_resources_error, + "Google denied the resource listing for project acme-prod. Run the setup script again: it grants Cloud Asset Viewer. (Cloud Asset Inventory returned 403 PERMISSION_DENIED)", ) assert.isNull(state.resources_synced_at) assert.isNull(state.last_metrics_error) @@ -1205,7 +1202,7 @@ describe("GcpMetricsService resource inventory", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_resources_error, - "The scope holds more than 10000 resources; the inventory is incomplete.", + "The scope holds more than 10,000 resources, so the resource list is incomplete. Metrics are unaffected. Connect folders or projects separately for a full list.", ) // Counted as synced, so the next attempt is in an hour, and nothing was removed. assert.strictEqual(state.resources_synced_at?.getTime(), now + 60 * minute) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index 1601f39379..f5236d61ca 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -11,7 +11,7 @@ */ import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" import { IntegrationsPersistenceError, UserId } from "@maple/domain/http" -import type { GcpProjectId, OrgId } from "@maple/domain/primitives" +import type { OrgId } from "@maple/domain/primitives" import { gcpConnectors, gcpResources, type GcpConnectorRow } from "@maple/db" import { and, asc, eq, isNull, lt, or, sql } from "drizzle-orm" import { Cause, Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" @@ -71,22 +71,45 @@ const METRICS = GCP_METRIC_GROUPS.flatMap((group) => group.metrics.map((metric) const SYSTEM_USER_ID = Schema.decodeUnknownSync(UserId)("system-gcp-metrics") -const SETUP_HINT = - "Run the setup script in Cloud Shell to grant Maple read access; if it already ran, wait a few minutes for the grant to apply." +const RETRIES = "Maple retries in 5 minutes." +const NOT_STORED = `${RETRIES} Nothing to do.` -/** What the connection shows for a failed call. The host project is the one Google checks. */ -const describeApiError = (error: GcpApiError, hostProject: GcpProjectId) => { +/** Why a failed call failed and what to do about it, in the customer's terms. */ +const explainApiError = ( + error: GcpApiError, + connector: GcpConnectorRow, + reading: "metrics" | "resources", +) => { + // The host project is the one Google checks for billing, enabled APIs and quota. + const host = connector.projectId + const scope = `${connector.scopeType} ${connector.scopeId}` if (error.reason === "BILLING_DISABLED") { - return `${error.message}. This API requires billing to be enabled on the host project ${hostProject}.` + return `The host project ${host} has no active billing account, and ${error.api} only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=${host} Maple retries every 5 minutes.` } if (error.reason === "SERVICE_DISABLED") { - return `${error.message}. This API is not enabled in the host project ${hostProject}; run the setup script again.` + return `The ${error.api} API is switched off in the host project ${host}. Run the setup script again: it switches the API on.` } - return error.kind === "denied" || error.kind === "not_found" - ? `${error.message}. ${SETUP_HINT}` - : error.message + if (error.kind === "denied" || error.kind === "not_found") { + if (error.api === "IAM Credentials") { + return "Maple can't sign in as this connection's read-only service account. It was deleted, or the grant to Maple was removed or blocked by an organization policy. Run the setup script again and read its last lines." + } + return reading === "metrics" + ? `Google denied Maple's read of ${scope}: the read-only roles are missing. Run the setup script again: it grants them. A new grant can take a few minutes.` + : `Google denied the resource listing for ${scope}. Run the setup script again: it grants Cloud Asset Viewer.` + } + if (error.kind === "rate_limited") { + return `Google rate-limited the ${error.api} API for the host project ${host}. Maple kept what it read and retries in 5 minutes. If this repeats, raise that API's quota on the project.` + } + return `Google's ${error.api} API did not answer Maple's request. ${RETRIES} If this repeats, write to support@maple.dev.` } +/** + * What the connection shows for a failed call: one sentence of cause, one of what to do, and + * Google's status at the end. + */ +const describeApiError = (error: GcpApiError, connector: GcpConnectorRow, reading: "metrics" | "resources") => + `${explainApiError(error, connector, reading)} (${error.message})` + const toPersistenceError = makePersistenceErrorMapper( IntegrationsPersistenceError, "Google Cloud metrics database error", @@ -298,7 +321,7 @@ export class GcpMetricsService extends Context.Service Effect.succeed("The inventory sync ran out of time; it is incomplete."), + orElse: () => + Effect.succeed( + "The resource listing ran out of time and is incomplete. Maple retries within the hour.", + ), }), Effect.map((note) => ({ lastResourcesError: note, @@ -324,10 +350,12 @@ export class GcpMetricsService extends Context.Service Effect.succeed({ - lastResourcesError: describeApiError(error, connector.projectId), + lastResourcesError: describeApiError(error, connector, "resources"), }), "@maple/http/errors/IntegrationsPersistenceError": () => - Effect.succeed({ lastResourcesError: "Maple could not store the inventory." }), + Effect.succeed({ + lastResourcesError: `Maple could not store the resource list just now. ${NOT_STORED}`, + }), }), ) @@ -434,12 +462,12 @@ export class GcpMetricsService extends Context.Service recordFailure( connector, - "Reading the metrics took too long. Maple retries on the next poll.", + `Reading the metrics took longer than two minutes. ${RETRIES} If this repeats, connect folders or projects separately.`, ), }), Effect.catchTags({ "@maple/api/integrations/GcpApiError": (error) => - recordFailure(connector, describeApiError(error, connector.projectId)), + recordFailure(connector, describeApiError(error, connector, "metrics")), "@maple/api/integrations/GcpMetricsIngestError": (error) => error.status === 402 ? recordFailure( connector, - "Metrics are paused: this organization is over its plan limit.", + "Metrics are paused: this Maple organization is over its plan limit. Maple tries again in an hour. See Settings, Billing.", tick.now + BILLING_HOLD_MS, ) - : recordFailure(connector, error.message), + : recordFailure( + connector, + `Maple could not store the metrics it read just now. ${NOT_STORED}`, + ), }), ), ) diff --git a/packages/backend/src/services/integrations/gcp/api.ts b/packages/backend/src/services/integrations/gcp/api.ts index d2a8c15a14..742aa5ecc9 100644 --- a/packages/backend/src/services/integrations/gcp/api.ts +++ b/packages/backend/src/services/integrations/gcp/api.ts @@ -3,6 +3,8 @@ * The Google calls the poller makes, over WebCrypto and `fetch` so they run in a Worker. Nothing * here may leak a credential: tokens stay `Redacted`, and a failure carries the HTTP status and * Google's error code and reason only, never a response body, transport error or schema issue. + * The one sentence of Google's that is read, to tell a missing billing account from a missing + * role, chooses a reason and is dropped. * Untraced on purpose: the HTTP client records a span per request, and "not set up yet" is not an * exception. */ @@ -31,8 +33,17 @@ const REQUEST_TIMEOUT = Duration.seconds(20) const TIME_SERIES_PAGE_SIZE = 2_000 export const RESOURCE_PAGE_SIZE = 500 +/** The Google APIs the poller calls, as a connection's error text names them. */ +const GcpApi = Schema.Literals([ + "Google OAuth", + "IAM Credentials", + "Cloud Monitoring", + "Cloud Asset Inventory", +]) + export class GcpApiError extends Schema.TaggedError()("@maple/api/integrations/GcpApiError", { message: Schema.String, + api: GcpApi, /** * `denied`: 401/403. `not_found`: 404. `rate_limited`: 429. `invalid`: any other 4xx. * `upstream`: 5xx, transport, timeout or an undecodable body. @@ -84,28 +95,46 @@ const decodeErrorInfo = Schema.decodeUnknownOption( }), ) -const errorReason = (body: unknown): string | undefined => +const decodeErrorMessage = Schema.decodeUnknownOption( + Schema.Struct({ error: Schema.Struct({ message: Schema.String }) }), +) + +// Cloud Monitoring refuses a project without billing with a bare 403: no `ErrorInfo`, only a +// sentence. Its wording is the one thing that tells that refusal from a missing role. +const reasonFromMessage = (body: unknown): string | undefined => { + const message = Option.getOrUndefined(decodeErrorMessage(body))?.error.message + if (message === undefined) return undefined + if (message.includes("billing")) return "BILLING_DISABLED" + return message.includes("has not been used") || message.includes("is disabled") + ? "SERVICE_DISABLED" + : undefined +} + +const errorReason = (body: unknown, kind: GcpApiError["kind"]): string | undefined => Option.toArray(decodeErrorDetails(body)) .flatMap((decoded) => decoded.error.details) - .flatMap((detail) => Option.toArray(decodeErrorInfo(detail)))[0]?.reason + .flatMap((detail) => Option.toArray(decodeErrorInfo(detail)))[0]?.reason ?? + (kind === "denied" ? reasonFromMessage(body) : undefined) -/** One JSON call. `api` names the Google service in the error message. */ +/** One JSON call. `api` names the Google service in the error. */ const call = ( httpClient: HttpClient.HttpClient, - api: string, + api: GcpApiError["api"], request: HttpClientRequest.HttpClientRequest, decode: (body: unknown) => Effect.Effect, ): Effect.Effect => { const failed = (what: string) => () => - Effect.fail(new GcpApiError({ message: `${api} ${what}`, kind: "upstream" })) + Effect.fail(new GcpApiError({ message: `${api} ${what}`, api, kind: "upstream" })) return Effect.gen(function* () { const response = yield* httpClient.execute(request) const body = yield* response.json.pipe(Effect.orElseSucceed(() => null)) if (response.status >= 300) { - const reason = errorReason(body) + const kind = errorKind(response.status) + const reason = errorReason(body, kind) return yield* new GcpApiError({ - message: `${api} returned ${response.status}${errorCode(body)}${reason === undefined ? "" : ` (${reason})`}`, - kind: errorKind(response.status), + message: `${api} returned ${response.status}${errorCode(body)}`, + api, + kind, ...(reason === undefined ? undefined : { reason }), }) } @@ -159,6 +188,7 @@ export const importServiceAccountKey = (encoded: Redacted.Redacted) => () => new GcpApiError({ message: "MAPLE_GCP_SERVICE_ACCOUNT_KEY is not a base64-encoded service account key", + api: "Google OAuth", kind: "invalid", }), ), @@ -237,7 +267,7 @@ export const impersonateReader = Effect.fnUntraced(function* ( const account = `${gcpConnectorResourceNames(connector.id).serviceAccountId}@${encodeURIComponent(connector.projectId)}.iam.gserviceaccount.com` const response = yield* call( httpClient, - "Google IAM", + "IAM Credentials", HttpClientRequest.post( `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${account}:generateAccessToken`, ).pipe( @@ -250,7 +280,7 @@ export const impersonateReader = Effect.fnUntraced(function* ( decodeImpersonatedToken, ).pipe( // Not made through the host project, so its reason says nothing about that project. - Effect.mapError(({ message, kind }) => new GcpApiError({ message, kind })), + Effect.mapError(({ message, api, kind }) => new GcpApiError({ message, api, kind })), ) return { accessToken: response.accessToken, quotaProject: connector.projectId } satisfies GcpReader }) diff --git a/packages/domain/src/http/v2/integrations-gcp.ts b/packages/domain/src/http/v2/integrations-gcp.ts index 35ac57fb25..a2b04c9746 100644 --- a/packages/domain/src/http/v2/integrations-gcp.ts +++ b/packages/domain/src/http/v2/integrations-gcp.ts @@ -100,7 +100,7 @@ export const V2GcpConnector = Schema.Struct({ }), last_metrics_error: Schema.NullOr(Schema.String).annotate({ description: - "Why the most recent metrics read failed or was incomplete, or `null`. Before the setup script has run it says that Maple has no access yet.", + "Why the most recent metrics read failed or was incomplete and what to do about it, or `null`. Before the setup script has run it says that Maple cannot sign in yet: `applied_metrics_enabled` tells that state apart.", examples: [null], }), discovered_project_count: Schema.Number.annotate({ From 8ad792bf17fa273313b65f6472f2c6dca4b13951 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 02:27:27 +0200 Subject: [PATCH 09/17] fix(integrations): Google Cloud metrics error texts that hold for every case they show in The no-billing answer is recognised by Google's own phrase, not by any sentence that names billing. A rate limit keeps its quota advice when part of the read succeeded, a failed request no longer reads as an unanswered one, and the cut-short note is true for queries a budget skipped. Google's reason code is back in the status at the end. --- .../integrations/GcpMetricsService.test.ts | 21 ++++++++++--------- .../integrations/GcpMetricsService.ts | 20 +++++++++++------- .../src/services/integrations/gcp/api.ts | 4 ++-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index f1353b25cb..be3448d409 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -157,7 +157,7 @@ const READ_DENIED = const CANNOT_SIGN_IN = "Maple can't sign in as this connection's read-only service account. It was deleted, or the grant to Maple was removed or blocked by an organization policy. Run the setup script again and read its last lines. (IAM Credentials returned 403 PERMISSION_DENIED)" const NO_BILLING = - "The host project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED)" + "The host project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED, BILLING_DISABLED)" const queriesFailed = (failed: number, type: string, answer: string) => `${failed} of ${METRIC_COUNT} metric queries failed, first ${type}. The rest were stored. Maple retries in 5 minutes. (Cloud Monitoring returned ${answer})` @@ -709,7 +709,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.strictEqual( state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries hold more series than one read takes. Connect the folders or projects separately to collect all of it.`, + `1 of ${METRIC_COUNT} metric queries were not read in full: the scope holds more series than one read takes. Connect the folders or projects separately to collect all of it.`, ) }), ) @@ -845,7 +845,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(yield* metricsError, NO_BILLING) assert.strictEqual( (yield* pollState(testDb, CONNECTOR_A)).last_resources_error, - "The Cloud Asset Inventory API is switched off in the host project acme-prod. Run the setup script again: it switches the API on. (Cloud Asset Inventory returned 403 PERMISSION_DENIED)", + "The Cloud Asset Inventory API is switched off in the host project acme-prod. Run the setup script again: it switches the API on. (Cloud Asset Inventory returned 403 PERMISSION_DENIED, SERVICE_DISABLED)", ) monitoring = () => googleError(403, "PERMISSION_DENIED", [errorInfo("BILLING_DISABLED")]) @@ -864,7 +864,10 @@ describe("GcpMetricsService", () => { impersonate = () => googleError(403, "PERMISSION_DENIED", [errorInfo("SERVICE_DISABLED")]) yield* TestClock.setTime(now + 15 * minute) yield* pollAll - assert.strictEqual(yield* metricsError, CANNOT_SIGN_IN) + assert.strictEqual( + yield* metricsError, + CANNOT_SIGN_IN.replace("DENIED)", "DENIED, SERVICE_DISABLED)"), + ) // Details of another form cost neither the status code nor the poll. impersonate = () => undefined @@ -934,11 +937,9 @@ describe("GcpMetricsService", () => { assert.deepInclude(yield* pollAll, { polled: 1, failures: 0 }) assert.lengthOf(calls.monitoring, 2) const state = yield* pollState(testDb, CONNECTOR_A) - assert.match( - state.last_metrics_error ?? "", - new RegExp( - `^${METRIC_COUNT - 1} of ${METRIC_COUNT} metric queries failed, first .+\\. The rest were stored\\. Maple retries in 5 minutes\\. \\(Cloud Monitoring returned 429 RESOURCE_EXHAUSTED\\)$`, - ), + assert.strictEqual( + state.last_metrics_error, + "Google rate-limited the Cloud Monitoring API for the host project acme-prod. Maple retries in 5 minutes. If this repeats, raise that API's quota on the project. (Cloud Monitoring returned 429 RESOURCE_EXHAUSTED)", ) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) }), @@ -972,7 +973,7 @@ describe("GcpMetricsService", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_metrics_error, - "Google's Cloud Monitoring API did not answer Maple's request. Maple retries in 5 minutes. If this repeats, write to support@maple.dev. (Cloud Monitoring request timed out)", + "Maple's request to Google's Cloud Monitoring API failed. Maple retries in 5 minutes. If this repeats, write to support@maple.dev. (Cloud Monitoring request timed out)", ) assert.isNull(state.metrics_watermark_at) }), diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index f5236d61ca..60f1d5416d 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -97,10 +97,11 @@ const explainApiError = ( ? `Google denied Maple's read of ${scope}: the read-only roles are missing. Run the setup script again: it grants them. A new grant can take a few minutes.` : `Google denied the resource listing for ${scope}. Run the setup script again: it grants Cloud Asset Viewer.` } - if (error.kind === "rate_limited") { - return `Google rate-limited the ${error.api} API for the host project ${host}. Maple kept what it read and retries in 5 minutes. If this repeats, raise that API's quota on the project.` + // Signing in is counted against Maple's own project, not the host project. + if (error.kind === "rate_limited" && error.api !== "IAM Credentials") { + return `Google rate-limited the ${error.api} API for the host project ${host}. ${RETRIES} If this repeats, raise that API's quota on the project.` } - return `Google's ${error.api} API did not answer Maple's request. ${RETRIES} If this repeats, write to support@maple.dev.` + return `Maple's request to Google's ${error.api} API failed. ${RETRIES} If this repeats, write to support@maple.dev.` } /** @@ -458,16 +459,19 @@ export class GcpMetricsService extends Context.Service { const message = Option.getOrUndefined(decodeErrorMessage(body))?.error.message if (message === undefined) return undefined - if (message.includes("billing")) return "BILLING_DISABLED" + if (message.includes("requires billing")) return "BILLING_DISABLED" return message.includes("has not been used") || message.includes("is disabled") ? "SERVICE_DISABLED" : undefined @@ -132,7 +132,7 @@ const call = ( const kind = errorKind(response.status) const reason = errorReason(body, kind) return yield* new GcpApiError({ - message: `${api} returned ${response.status}${errorCode(body)}`, + message: `${api} returned ${response.status}${errorCode(body)}${reason === undefined ? "" : `, ${reason}`}`, api, kind, ...(reason === undefined ? undefined : { reason }), From 8ea599008d943b93b77b87e873d4dea7f2c5833d Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 02:46:26 +0200 Subject: [PATCH 10/17] fix(integrations): Google Cloud percentiles below the first bucket, no nameless load balancer, sign-in text for a fresh setup The underflow bucket is read from zero, as Cloud Monitoring's own percentile reducers do: a service answering in 3 ms reported p50 10 ms. Cloud Run traffic on Google's default domain arrives as a load balancer rule without a URL map and is dropped. The sign-in error reads right both before the script ran and in the minute or two after a run. --- .../integrations/GcpMetricsService.test.ts | 2 +- .../integrations/GcpMetricsService.ts | 4 ++- .../services/integrations/gcp/mapping.test.ts | 27 +++++++++++++++++++ .../src/services/integrations/gcp/mapping.ts | 10 +++++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index be3448d409..705f048411 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -155,7 +155,7 @@ const NO_BILLING_ANSWER = { const READ_DENIED = "Google denied Maple's read of project acme-prod: the read-only roles are missing. Run the setup script again: it grants them. A new grant can take a few minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED)" const CANNOT_SIGN_IN = - "Maple can't sign in as this connection's read-only service account. It was deleted, or the grant to Maple was removed or blocked by an organization policy. Run the setup script again and read its last lines. (IAM Credentials returned 403 PERMISSION_DENIED)" + "Maple can't sign in as this connection's read-only service account yet. After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes. If this stays, the account does not exist, or the grant to Maple is missing or blocked by an organization policy: run the setup script and read its last lines. (IAM Credentials returned 403 PERMISSION_DENIED)" const NO_BILLING = "The host project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED, BILLING_DISABLED)" const queriesFailed = (failed: number, type: string, answer: string) => diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index 60f1d5416d..48150ed1ea 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -90,8 +90,10 @@ const explainApiError = ( return `The ${error.api} API is switched off in the host project ${host}. Run the setup script again: it switches the API on.` } if (error.kind === "denied" || error.kind === "not_found") { + // Shown both before the script ever ran and for the minute or two after a run in which + // Google does not accept the new grant yet, so it has to read right in both. if (error.api === "IAM Credentials") { - return "Maple can't sign in as this connection's read-only service account. It was deleted, or the grant to Maple was removed or blocked by an organization policy. Run the setup script again and read its last lines." + return "Maple can't sign in as this connection's read-only service account yet. After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes. If this stays, the account does not exist, or the grant to Maple is missing or blocked by an organization policy: run the setup script and read its last lines." } return reading === "metrics" ? `Google denied Maple's read of ${scope}: the read-only roles are missing. Run the setup script again: it grants them. A new grant can take a few minutes.` diff --git a/packages/backend/src/services/integrations/gcp/mapping.test.ts b/packages/backend/src/services/integrations/gcp/mapping.test.ts index de196c543f..2624730930 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.test.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.test.ts @@ -124,6 +124,21 @@ describe("mapGcpTimeSeries", () => { service_name: "resize", }) }) + + it("drops a load balancer series that names no URL map", () => { + const series = (url_map_name: string) => ({ + metric: { labels: { response_code_class: "200" } }, + resource: { labels: { project_id: "acme-prod", url_map_name, backend_target_name: "" } }, + points: [{ interval: { endTime: at(2) }, value: { int64Value: "3" } }], + }) + const { sumRows } = map("loadbalancing.googleapis.com/https/request_count", [ + series(""), + series("checkout-lb"), + ]) + expect(sumRows.map((row) => row.resource_attributes["gcp.resource.labels.url_map_name"])).toEqual([ + "checkout-lb", + ]) + }) }) describe("distributionQuantile", () => { @@ -146,6 +161,18 @@ describe("distributionQuantile", () => { expect(distributionQuantile(explicit, 0.75)).toBe(15) expect(distributionQuantile({}, 0.5)).toBeUndefined() }) + + it("reads the underflow bucket from zero, as Cloud Monitoring does", () => { + // A real `run.googleapis.com/request_latencies` point: 25 requests of about 3 ms, all below + // the first bound of 10 ms. Google's REDUCE_PERCENTILE_50/95/99 answered 5, 9.5 and 9.9. + const fast = { + bucketOptions: { exponentialBuckets: { numFiniteBuckets: 135, growthFactor: 1.1, scale: 10 } }, + bucketCounts: ["25"], + } + expect(distributionQuantile(fast, 0.5)).toBe(5) + expect(distributionQuantile(fast, 0.95)).toBe(9.5) + expect(distributionQuantile(fast, 0.99)).toBeCloseTo(9.9, 10) + }) }) describe("gcpResourceAttributes", () => { diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index 922266f991..6e67d656f0 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -78,11 +78,14 @@ export const gcpResourceAttributes = ( /** * Bounds of bucket `index`. Bucket 0 is the underflow bucket and the last one the overflow - * bucket; both are open-ended, so they collapse to their one finite bound. + * bucket. The overflow bucket is open-ended and collapses to its one finite bound. The + * underflow bucket starts at zero when its bound is positive, which is how Cloud Monitoring's + * percentile reducers read it: a Cloud Run service answering in 3 ms has every request below + * the first bound of 10 ms, and Google reports p50 5 ms for it, not 10. */ const bucketBounds = (options: GcpBucketOptions, index: number): readonly [number, number] => { const between = (bound: (edge: number) => number, lastEdge: number): readonly [number, number] => [ - bound(Math.max(index - 1, 0)), + index === 0 ? Math.min(0, bound(0)) : bound(index - 1), bound(Math.min(index, lastEdge)), ] if (options.linearBuckets) { @@ -159,6 +162,9 @@ export const mapGcpTimeSeries = ( const { group, metric } = query const rows: GcpMetricRows = { sumRows: [], gaugeRows: [] } for (const item of series) { + // Cloud Run traffic on Google's default domain is counted under a load balancer rule with + // no URL map: a load balancer the customer does not have. + if (group.resourceType === "https_lb_rule" && !item.resource.labels?.url_map_name) continue const metricLabels = item.metric.labels ?? {} const resourceAttributes = gcpResourceAttributes( group.resourceType, From dbccc76c79c20901b679d48f8149d4895a248714 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Fri, 9 Oct 2026 11:25:37 +0200 Subject: [PATCH 11/17] fix(integrations): Google Cloud poller texts by history and scope A connection that was read before and can no longer sign in says so, instead of asking to wait for a new grant. A project-scope connection is not told to connect folders or projects separately, and reads the project where a folder or organization reads the host project. --- .../integrations/GcpMetricsService.test.ts | 71 +++++++++++++++++-- .../integrations/GcpMetricsService.ts | 31 +++++--- 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 705f048411..1d9f17cc7e 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -157,7 +157,7 @@ const READ_DENIED = const CANNOT_SIGN_IN = "Maple can't sign in as this connection's read-only service account yet. After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes. If this stays, the account does not exist, or the grant to Maple is missing or blocked by an organization policy: run the setup script and read its last lines. (IAM Credentials returned 403 PERMISSION_DENIED)" const NO_BILLING = - "The host project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED, BILLING_DISABLED)" + "The project acme-prod has no active billing account, and Cloud Monitoring only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=acme-prod Maple retries every 5 minutes. (Cloud Monitoring returned 403 PERMISSION_DENIED, BILLING_DISABLED)" const queriesFailed = (failed: number, type: string, answer: string) => `${failed} of ${METRIC_COUNT} metric queries failed, first ${type}. The rest were stored. Maple retries in 5 minutes. (Cloud Monitoring returned ${answer})` @@ -683,6 +683,67 @@ describe("GcpMetricsService", () => { ) }) + it.effect("says a connection that was read before can no longer sign in", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + let impersonate: () => Response | undefined = () => undefined + const stub = stubFetch(calls, { impersonate: () => impersonate(), monitoring: onlyRequestCount }) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 0 }) + impersonate = () => googleError(403, "PERMISSION_DENIED") + yield* TestClock.setTime(now + 5 * minute) + assert.deepInclude(yield* pollAll, { polled: 1, failures: 1 }) + const state = yield* pollState(testDb, CONNECTOR_A) + assert.strictEqual( + state.last_metrics_error, + "Maple can no longer sign in as this connection's read-only service account. The account was deleted, or the grant to Maple was removed or is blocked by an organization policy. Run the setup script again: it restores both, or says what blocks it. (IAM Credentials returned 403 PERMISSION_DENIED)", + ) + assert.strictEqual(state.last_metrics_received_at?.getTime(), now) + }), + ) + }) + + it.effect("names an organization's host project, and its parts when it is too large", () => { + const testDb = createTestDb(trackedDbs) + const calls = makeCalls() + // The first query is answered, the second is rate limited. + let monitoring = (_call: MonitoringCall): Response | undefined => + calls.monitoring.length === 1 + ? json(requestCountSeries()) + : googleError(429, "RESOURCE_EXHAUSTED") + const stub = stubFetch(calls, { monitoring: (call) => monitoring(call) }) + const metricsError = pollState(testDb, CONNECTOR_A).pipe( + Effect.map((state) => state.last_metrics_error), + ) + return run( + testDb, + stub, + Effect.gen(function* () { + yield* insertConnector(testDb, CONNECTOR_A, { scope: ["organization", "123456789012"] }) + yield* pollAll + assert.strictEqual( + yield* metricsError, + "Google rate-limited the Cloud Monitoring API for the host project acme-prod. Maple retries in 5 minutes. If this repeats, raise that API's quota on the project. (Cloud Monitoring returned 429 RESOURCE_EXHAUSTED)", + ) + + monitoring = (call) => + call.metricType === REQUEST_COUNT + ? json({ ...requestCountSeries(), nextPageToken: `page-${calls.monitoring.length}` }) + : undefined + yield* TestClock.setTime(now + 5 * minute) + yield* pollAll + assert.strictEqual( + yield* metricsError, + `1 of ${METRIC_COUNT} metric queries were not read in full: the organization holds more series than one read takes. To collect all of it, connect its folders or projects separately.`, + ) + }), + ) + }) + it.effect("stops paging a metric at the cap and reports it", () => { const testDb = createTestDb(trackedDbs) const calls = makeCalls() @@ -709,7 +770,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) assert.strictEqual( state.last_metrics_error, - `1 of ${METRIC_COUNT} metric queries were not read in full: the scope holds more series than one read takes. Connect the folders or projects separately to collect all of it.`, + `1 of ${METRIC_COUNT} metric queries were not read in full: the project holds more series than one read takes. To collect all of it, write to support@maple.dev.`, ) }), ) @@ -845,7 +906,7 @@ describe("GcpMetricsService", () => { assert.strictEqual(yield* metricsError, NO_BILLING) assert.strictEqual( (yield* pollState(testDb, CONNECTOR_A)).last_resources_error, - "The Cloud Asset Inventory API is switched off in the host project acme-prod. Run the setup script again: it switches the API on. (Cloud Asset Inventory returned 403 PERMISSION_DENIED, SERVICE_DISABLED)", + "The Cloud Asset Inventory API is switched off in the project acme-prod. Run the setup script again: it switches the API on. (Cloud Asset Inventory returned 403 PERMISSION_DENIED, SERVICE_DISABLED)", ) monitoring = () => googleError(403, "PERMISSION_DENIED", [errorInfo("BILLING_DISABLED")]) @@ -939,7 +1000,7 @@ describe("GcpMetricsService", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_metrics_error, - "Google rate-limited the Cloud Monitoring API for the host project acme-prod. Maple retries in 5 minutes. If this repeats, raise that API's quota on the project. (Cloud Monitoring returned 429 RESOURCE_EXHAUSTED)", + "Google rate-limited the Cloud Monitoring API for the project acme-prod. Maple retries in 5 minutes. If this repeats, raise that API's quota on the project. (Cloud Monitoring returned 429 RESOURCE_EXHAUSTED)", ) assert.strictEqual(state.metrics_watermark_at?.getTime(), horizon) }), @@ -1203,7 +1264,7 @@ describe("GcpMetricsService resource inventory", () => { const state = yield* pollState(testDb, CONNECTOR_A) assert.strictEqual( state.last_resources_error, - "The scope holds more than 10,000 resources, so the resource list is incomplete. Metrics are unaffected. Connect folders or projects separately for a full list.", + "The project holds more than 10,000 resources, so the resource list is incomplete. Metrics are unaffected. For a full list, write to support@maple.dev.", ) // Counted as synced, so the next attempt is in an hour, and nothing was removed. assert.strictEqual(state.resources_synced_at?.getTime(), now + 60 * minute) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index 48150ed1ea..86c54a387f 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -74,25 +74,36 @@ const SYSTEM_USER_ID = Schema.decodeUnknownSync(UserId)("system-gcp-metrics") const RETRIES = "Maple retries in 5 minutes." const NOT_STORED = `${RETRIES} Nothing to do.` +/** What helps a scope too large for one read. A project has no parts to connect instead. */ +const whenTooLarge = (connector: GcpConnectorRow) => + connector.scopeType === "project" + ? "write to support@maple.dev" + : "connect its folders or projects separately" + /** Why a failed call failed and what to do about it, in the customer's terms. */ const explainApiError = ( error: GcpApiError, connector: GcpConnectorRow, reading: "metrics" | "resources", ) => { - // The host project is the one Google checks for billing, enabled APIs and quota. - const host = connector.projectId + // The host project is the one Google checks for billing, enabled APIs and quota. A project + // scope is its own host, and reads as "the project". + const host = `${connector.scopeType === "project" ? "project" : "host project"} ${connector.projectId}` const scope = `${connector.scopeType} ${connector.scopeId}` if (error.reason === "BILLING_DISABLED") { - return `The host project ${host} has no active billing account, and ${error.api} only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=${host} Maple retries every 5 minutes.` + return `The ${host} has no active billing account, and ${error.api} only answers for projects that have one. Link one: https://console.cloud.google.com/billing/linkedaccount?project=${connector.projectId} Maple retries every 5 minutes.` } if (error.reason === "SERVICE_DISABLED") { - return `The ${error.api} API is switched off in the host project ${host}. Run the setup script again: it switches the API on.` + return `The ${error.api} API is switched off in the ${host}. Run the setup script again: it switches the API on.` } if (error.kind === "denied" || error.kind === "not_found") { - // Shown both before the script ever ran and for the minute or two after a run in which - // Google does not accept the new grant yet, so it has to read right in both. if (error.api === "IAM Credentials") { + // A connection that has been read before lost its account or its grant. + if (connector.lastMetricsReceivedAt !== null) { + return "Maple can no longer sign in as this connection's read-only service account. The account was deleted, or the grant to Maple was removed or is blocked by an organization policy. Run the setup script again: it restores both, or says what blocks it." + } + // Shown both before the script ever ran and for the minute or two after a run in + // which Google does not accept the new grant yet, so it has to read right in both. return "Maple can't sign in as this connection's read-only service account yet. After a setup run Google needs a few minutes to accept the new grant, and Maple retries every 5 minutes. If this stays, the account does not exist, or the grant to Maple is missing or blocked by an organization policy: run the setup script and read its last lines." } return reading === "metrics" @@ -101,7 +112,7 @@ const explainApiError = ( } // Signing in is counted against Maple's own project, not the host project. if (error.kind === "rate_limited" && error.api !== "IAM Credentials") { - return `Google rate-limited the ${error.api} API for the host project ${host}. ${RETRIES} If this repeats, raise that API's quota on the project.` + return `Google rate-limited the ${error.api} API for the ${host}. ${RETRIES} If this repeats, raise that API's quota on the project.` } return `Maple's request to Google's ${error.api} API failed. ${RETRIES} If this repeats, write to support@maple.dev.` } @@ -324,7 +335,7 @@ export class GcpMetricsService extends Context.Service recordFailure( connector, - `Reading the metrics took longer than two minutes. ${RETRIES} If this repeats, connect folders or projects separately.`, + `Reading the metrics took longer than two minutes. ${RETRIES} If this repeats, ${whenTooLarge(connector)}.`, ), }), Effect.catchTags({ From 44c15f1db35c8dcf72fbb02724f20e2028c008cb Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Sat, 10 Oct 2026 02:52:58 +0200 Subject: [PATCH 12/17] refactor(integrations): Google Cloud poller and inventory sync on effect-orm Same statements as before. The lease claim is still one conditional UPDATE ... RETURNING, the inventory sync still upserts by (connector_id, name) from the proposed row and then deletes what the sync did not return, and a billing hold still moves the lease of every enabled connector of the organization. effect-orm has no NULLS FIRST, so the due list orders by (lease IS NULL) DESC, lease, id: never polled first, as before. Timestamps are epoch milliseconds from the tables, so nextWindow and the resource mapping take and return numbers. --- .../integrations/GcpMetricsService.test.ts | 8 +- .../integrations/GcpMetricsService.ts | 160 +++++++++--------- .../services/integrations/gcp/mapping.test.ts | 2 +- .../src/services/integrations/gcp/mapping.ts | 10 +- 4 files changed, 88 insertions(+), 92 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 1d9f17cc7e..183cd3c6b5 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -26,13 +26,13 @@ describe("nextWindow", () => { }) it("continues from the watermark, and is null once caught up", () => { - const watermark = new Date(horizon - 5 * minute) - assert.deepStrictEqual(nextWindow(watermark, now), { startMs: watermark.getTime(), endMs: horizon }) - assert.isNull(nextWindow(new Date(horizon), now)) + const watermark = horizon - 5 * minute + assert.deepStrictEqual(nextWindow(watermark, now), { startMs: watermark, endMs: horizon }) + assert.isNull(nextWindow(horizon, now)) }) it("never reaches back more than an hour: a long pause is skipped, not replayed", () => { - const watermark = new Date(horizon - 48 * 60 * minute) + const watermark = horizon - 48 * 60 * minute assert.deepStrictEqual(nextWindow(watermark, now), { startMs: horizon - 60 * minute, endMs: horizon, diff --git a/packages/backend/src/services/integrations/GcpMetricsService.ts b/packages/backend/src/services/integrations/GcpMetricsService.ts index 86c54a387f..7db7f3df1f 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.ts @@ -12,15 +12,14 @@ import { GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" import { IntegrationsPersistenceError, UserId } from "@maple/domain/http" import type { OrgId } from "@maple/domain/primitives" -import { gcpConnectors, gcpResources, type GcpConnectorRow } from "@maple/db" -import { and, asc, eq, isNull, lt, or, sql } from "drizzle-orm" +import * as PG from "@maple-dev/effect-orm/postgres" +import { GcpConnectors, GcpResources, type GcpConnectorRow } from "@maple/db/tables" import { Cause, Clock, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute, makePersistenceErrorMapper } from "@maple/backend/platform/db-execute" import { summarizeCause } from "@maple/backend/platform/describe-cause" import { Env } from "@maple/backend/platform/Env" -import { dateToMs, msToDate } from "@maple/backend/platform/time" import { OrgIngestKeysService } from "@maple/backend/services/org/OrgIngestKeysService" import { metricRowsToOtlp } from "./cloudflare-analytics/otlp" import { @@ -138,9 +137,9 @@ class GcpMetricsIngestError extends Schema.TaggedError()( * The connector's next window, on minute boundaries, or null when it is caught up. It never * starts more than an hour back: after a long pause the gap is skipped, not replayed. */ -export const nextWindow = (watermarkAt: Date | null, now: number) => { +export const nextWindow = (watermarkAt: number | null, now: number) => { const endMs = now - INGESTION_LAG_MS - ((now - INGESTION_LAG_MS) % MINUTE_MS) - const startMs = Math.max(dateToMs(watermarkAt) ?? 0, endMs - MAX_WINDOW_MS) + const startMs = Math.max(watermarkAt ?? 0, endMs - MAX_WINDOW_MS) return startMs < endMs ? { startMs, endMs } : null } @@ -197,10 +196,14 @@ export class GcpMetricsService extends Context.Service, - ) => dbExecute((db) => db.update(gcpConnectors).set(set).where(eq(gcpConnectors.id, id))) + const updateConnector = (id: GcpConnectorRow["id"], set: PG.UpdateSetOf) => + dbExecute((db) => + db.run( + PG.update(GcpConnectors) + .set(set) + .where(($) => [$.id.eq(id)]), + ), + ) /** * A failed poll leaves the watermark alone and says why. With `holdUntilMs` the whole @@ -217,22 +220,15 @@ export class GcpMetricsService extends Context.Service - holdUntilMs === undefined - ? db - .update(gcpConnectors) - .set({ lastMetricsError: message }) - .where(eq(gcpConnectors.id, connector.id)) - : db - .update(gcpConnectors) - .set({ lastMetricsError: message, metricsLeaseUntil: msToDate(holdUntilMs) }) - .where( - and( - eq(gcpConnectors.orgId, connector.orgId), - eq(gcpConnectors.metricsEnabled, true), - ), - ), - ) + yield* holdUntilMs === undefined + ? updateConnector(connector.id, { lastMetricsError: message }) + : dbExecute((db) => + db.run( + PG.update(GcpConnectors) + .set({ lastMetricsError: message, metricsLeaseUntil: holdUntilMs }) + .where(($) => [$.orgId.eq(connector.orgId), $.metricsEnabled.eq(true)]), + ), + ) return FAILED }) @@ -281,7 +277,7 @@ export class GcpMetricsService extends Context.Service 0) { yield* dbExecute((db) => - db - .insert(gcpResources) - .values( - [...rows.values()].map((resource) => ({ - ...resource, - connectorId: connector.id, - orgId: connector.orgId, - lastSeenAt, - })), - ) - .onConflictDoUpdate({ - target: [gcpResources.connectorId, gcpResources.name], - set: { - assetType: sql`excluded.asset_type`, - projectId: sql`excluded.project_id`, - location: sql`excluded.location`, - displayName: sql`excluded.display_name`, - state: sql`excluded.state`, - labels: sql`excluded.labels`, - resourceCreatedAt: sql`excluded.resource_created_at`, - resourceUpdatedAt: sql`excluded.resource_updated_at`, - lastSeenAt: sql`excluded.last_seen_at`, - }, - }), + db.run( + PG.insertInto(GcpResources) + .values( + [...rows.values()].map((resource) => ({ + ...resource, + connectorId: connector.id, + orgId: connector.orgId, + lastSeenAt, + })), + ) + .onConflictDoUpdate({ + target: ["connectorId", "name"], + set: (_, excluded) => ({ + assetType: excluded.assetType, + projectId: excluded.projectId, + location: excluded.location, + displayName: excluded.displayName, + state: excluded.state, + labels: excluded.labels, + resourceCreatedAt: excluded.resourceCreatedAt, + resourceUpdatedAt: excluded.resourceUpdatedAt, + lastSeenAt: excluded.lastSeenAt, + }), + }), + ), ) } pageToken = body.nextPageToken if (pageToken === undefined) { yield* dbExecute((db) => - db - .delete(gcpResources) - .where( - and( - eq(gcpResources.connectorId, connector.id), - lt(gcpResources.lastSeenAt, lastSeenAt), - ), - ), + db.run( + PG.deleteFrom(GcpResources).where(($) => [ + $.connectorId.eq(connector.id), + $.lastSeenAt.lt(lastSeenAt), + ]), + ), ) return null } @@ -359,7 +354,7 @@ export class GcpMetricsService extends Context.Service ({ lastResourcesError: note, - resourcesSyncedAt: msToDate(tick.now), + resourcesSyncedAt: tick.now, })), Effect.catchTags({ "@maple/api/integrations/GcpApiError": (error) => @@ -388,7 +383,7 @@ export class GcpMetricsService extends Context.Service= RESOURCE_SYNC_INTERVAL_MS + tick.now - connector.resourcesSyncedAt >= RESOURCE_SYNC_INTERVAL_MS ) { yield* updateConnector( connector.id, @@ -488,8 +483,8 @@ export class GcpMetricsService extends Context.Service) => [ + $.metricsEnabled.eq(true), + PG.or($.metricsLeaseUntil.isNull(), $.metricsLeaseUntil.lt(now)), + ] const due = yield* dbExecute((db) => - db - .select({ id: gcpConnectors.id, orgId: gcpConnectors.orgId }) - .from(gcpConnectors) - .where(isDue) - .orderBy( - sql`${gcpConnectors.metricsLeaseUntil} asc nulls first`, - asc(gcpConnectors.id), - ), + db.run( + PG.from(GcpConnectors) + .select("id", "orgId") + .where(isDue) + // Never polled first, then least recently polled. + .orderBy(($) => [ + [PG.asBoolean($.metricsLeaseUntil.isNull()), "desc"], + [$.metricsLeaseUntil, "asc"], + [$.id, "asc"], + ]), + ), ) const takenPerOrg = new Map() const candidates = due.filter((row) => { @@ -589,11 +584,12 @@ export class GcpMetricsService extends Context.Service - db - .update(gcpConnectors) - .set({ metricsLeaseUntil: msToDate(now + LEASE_MS) }) - .where(and(eq(gcpConnectors.id, candidate.id), isDue)) - .returning(), + db.run( + PG.update(GcpConnectors) + .set({ metricsLeaseUntil: now + LEASE_MS }) + .where(($) => [$.id.eq(candidate.id), ...isDue($)]) + .returning(), + ), ) return connector === undefined ? null : yield* pollConnector(connector, tick) }).pipe( diff --git a/packages/backend/src/services/integrations/gcp/mapping.test.ts b/packages/backend/src/services/integrations/gcp/mapping.test.ts index 2624730930..858a92e55d 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.test.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.test.ts @@ -264,7 +264,7 @@ describe("mapGcpResource", () => { displayName: "checkout", state: null, labels: { team: "payments" }, - resourceCreatedAt: new Date(Date.UTC(2026, 0, 2, 3, 4, 5)), + resourceCreatedAt: Date.UTC(2026, 0, 2, 3, 4, 5), resourceUpdatedAt: null, }) diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index 6e67d656f0..103c84fa62 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -13,7 +13,7 @@ import { type MetricGaugeRow, type MetricSumRow, } from "@maple/backend/services/warehouse/metric-rows" -import { msToDate, timestampMs } from "@maple/backend/platform/time" +import { timestampMs } from "@maple/backend/platform/time" import { Predicate } from "effect" import type { GcpBucketOptions, GcpResourceSearchResult, GcpTimeSeries } from "./api" @@ -118,9 +118,9 @@ export const distributionQuantile = (distribution: GcpDistribution, q: number): return undefined } -const toDate = (value: string | undefined): Date | null => { +const toMs = (value: string | undefined): number | null => { const ms = value === undefined ? Number.NaN : timestampMs(value) - return msToDate(Number.isNaN(ms) ? null : ms) + return Number.isNaN(ms) ? null : ms } /** The `gcp_resources` columns of a search result, or undefined when it names no project. */ @@ -141,8 +141,8 @@ export const mapGcpResource = (result: GcpResourceSearchResult) => { displayName: displayName ?? null, state: result.state ?? null, labels: result.labels ?? {}, - resourceCreatedAt: toDate(result.createTime), - resourceUpdatedAt: toDate(result.updateTime), + resourceCreatedAt: toMs(result.createTime), + resourceUpdatedAt: toMs(result.updateTime), } } From e327404bb04f2a26f30205dee706009048cf5215 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Wed, 14 Oct 2026 21:58:08 +0200 Subject: [PATCH 13/17] test(gcp): follow effect 4.0.4's opt-in header capture, 4xx client span status and concurrent connector order --- .../integrations/GcpMetricsService.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index 183cd3c6b5..b3b8fee0ef 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -1,7 +1,7 @@ import { afterEach, assert, beforeAll, describe, it } from "@effect/vitest" import { ConfigProvider, Effect, Exit, Fiber, Layer, Logger, References, Tracer } from "effect" import { TestClock } from "effect/testing" -import { FetchHttpClient } from "effect/http" +import { FetchHttpClient, HttpClient } from "effect/http" import { GCP_ASSET_TYPES, GCP_METRIC_GROUPS } from "@maple/domain/gcp-metrics" import { Env } from "@maple/backend/platform/Env" import { @@ -333,6 +333,8 @@ const run = ( ), Effect.provideService(Tracer.Tracer, recorder.tracer), Effect.provideService(Logger.CurrentLoggers, new Set([recorder.logger])), + // Header capture is opt-in; record every header so the credential checks see them. + Effect.provideService(HttpClient.TracerHeaderFilter, () => true), ) } @@ -677,7 +679,11 @@ describe("GcpMetricsService", () => { 2, ) assert.notInclude(recorder.text(), "must not be stored") - assert.deepStrictEqual(recorder.failedSpans(), []) + // The HTTP client fails its own span on the 403 (OTel client semantics); Maple's do not. + assert.deepStrictEqual( + recorder.failedSpans().filter((span) => !span.startsWith("POST: ")), + [], + ) }), { recorder }, ) @@ -1090,12 +1096,13 @@ describe("GcpMetricsService", () => { assert.deepInclude(yield* pollAll, { polled: 11, deferred: 2 }) assert.isNotNull((yield* pollState(testDb, other)).metrics_watermark_at) - // Never polled sorts first: the two that waited go before the ones that are due again. + // Never polled sorts first: the two that waited are taken before the ones that are due + // again. Connectors run concurrently, so the call order is not the candidate order. yield* TestClock.setTime(now + 5 * minute) const before = calls.impersonate.length assert.deepInclude(yield* pollAll, { polled: 11, deferred: 2 }) - const order = calls.impersonate.slice(before).map((call) => call.account.split("@")[1]) - assert.sameMembers(order.slice(0, 2), [ + const taken = calls.impersonate.slice(before).map((call) => call.account.split("@")[1]) + assert.includeMembers(taken, [ "acme-prod-10.iam.gserviceaccount.com", "acme-prod-11.iam.gserviceaccount.com", ]) From 250a0526f0b32e5005e43bd80c1dd759affe2d84 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 15:23:07 +0200 Subject: [PATCH 14/17] chore(db): regenerate the gcp_metrics_poller migration after main's newer migrations --- .../migration.sql | 0 .../snapshot.json | 42 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) rename packages/db/drizzle/{20261014195520_gcp_metrics_poller => 20261015132248_gcp_metrics_poller}/migration.sql (100%) rename packages/db/drizzle/{20261014195520_gcp_metrics_poller => 20261015132248_gcp_metrics_poller}/snapshot.json (99%) diff --git a/packages/db/drizzle/20261014195520_gcp_metrics_poller/migration.sql b/packages/db/drizzle/20261015132248_gcp_metrics_poller/migration.sql similarity index 100% rename from packages/db/drizzle/20261014195520_gcp_metrics_poller/migration.sql rename to packages/db/drizzle/20261015132248_gcp_metrics_poller/migration.sql diff --git a/packages/db/drizzle/20261014195520_gcp_metrics_poller/snapshot.json b/packages/db/drizzle/20261015132248_gcp_metrics_poller/snapshot.json similarity index 99% rename from packages/db/drizzle/20261014195520_gcp_metrics_poller/snapshot.json rename to packages/db/drizzle/20261015132248_gcp_metrics_poller/snapshot.json index 4ad49b2a6a..72a28688c7 100644 --- a/packages/db/drizzle/20261014195520_gcp_metrics_poller/snapshot.json +++ b/packages/db/drizzle/20261015132248_gcp_metrics_poller/snapshot.json @@ -1,9 +1,9 @@ { "version": "1", "dialect": "postgres", - "id": "890c51072d3038d21544957baa1dbe5f462e7978d030200b93f0247e800334a4", + "id": "c4f43f426f75e70f359656bd1ee53c98903de67e3401237dd9934c25bce5001c", "prevIds": [ - "5c9b427359d9ed071f9315c14436e3a7a45982daa38c8e68fd82bca545017584" + "07cb1858dcef99be1aaa58d1cd8109f0ed5e6d6f979a00467ef0a2c42926b57b" ], "entities": [ { @@ -3459,8 +3459,18 @@ { "kind": "column", "table": "cloudflare_analytics_state", - "name": "last_success_at", + "name": "grant_call_cap", "position": 14, + "type": "integer", + "notNull": false, + "default": null, + "identity": null + }, + { + "kind": "column", + "table": "cloudflare_analytics_state", + "name": "last_success_at", + "position": 15, "type": "timestamp with time zone", "notNull": false, "default": null, @@ -3470,7 +3480,7 @@ "kind": "column", "table": "cloudflare_analytics_state", "name": "last_error", - "position": 15, + "position": 16, "type": "text", "notNull": false, "default": null, @@ -3480,7 +3490,7 @@ "kind": "column", "table": "cloudflare_analytics_state", "name": "last_error_at", - "position": 16, + "position": 17, "type": "timestamp with time zone", "notNull": false, "default": null, @@ -3490,7 +3500,7 @@ "kind": "column", "table": "cloudflare_analytics_state", "name": "lease_until", - "position": 17, + "position": 18, "type": "timestamp with time zone", "notNull": false, "default": null, @@ -3500,7 +3510,7 @@ "kind": "column", "table": "cloudflare_analytics_state", "name": "created_at", - "position": 18, + "position": 19, "type": "timestamp with time zone", "notNull": true, "default": null, @@ -3510,7 +3520,7 @@ "kind": "column", "table": "cloudflare_analytics_state", "name": "updated_at", - "position": 19, + "position": 20, "type": "timestamp with time zone", "notNull": true, "default": null, @@ -10949,8 +10959,18 @@ { "kind": "column", "table": "vcs_installations", - "name": "installed_by_user_id", + "name": "ci_traces_enabled", "position": 11, + "type": "boolean", + "notNull": true, + "default": "true", + "identity": null + }, + { + "kind": "column", + "table": "vcs_installations", + "name": "installed_by_user_id", + "position": 12, "type": "text", "notNull": true, "default": null, @@ -10960,7 +10980,7 @@ "kind": "column", "table": "vcs_installations", "name": "created_at", - "position": 12, + "position": 13, "type": "timestamp with time zone", "notNull": true, "default": null, @@ -10970,7 +10990,7 @@ "kind": "column", "table": "vcs_installations", "name": "updated_at", - "position": 13, + "position": 14, "type": "timestamp with time zone", "notNull": true, "default": null, From abb3fa2f7ad0ecc768225334333b11003e293989 Mon Sep 17 00:00:00 2001 From: JeremyFunk Date: Thu, 15 Oct 2026 19:43:38 +0200 Subject: [PATCH 15/17] refactor(gcp): semconv-style metric names and attributes Explicit gcp.* names per Cloud Monitoring type, durations in seconds, byte counters as .io split by network.io.direction / disk.io.direction, metric labels under gcp.metric.labels.*, and the Compute Engine instance name promoted to the resource as gcp.gce.instance.name. --- .../integrations/GcpMetricsService.test.ts | 10 +- .../src/services/integrations/gcp/api.ts | 4 +- .../services/integrations/gcp/mapping.test.ts | 44 ++- .../src/services/integrations/gcp/mapping.ts | 20 +- packages/domain/src/gcp-metrics.test.ts | 40 +- packages/domain/src/gcp-metrics.ts | 365 ++++++++++++++---- 6 files changed, 373 insertions(+), 110 deletions(-) diff --git a/packages/backend/src/services/integrations/GcpMetricsService.test.ts b/packages/backend/src/services/integrations/GcpMetricsService.test.ts index b3b8fee0ef..0d8a3d330d 100644 --- a/packages/backend/src/services/integrations/GcpMetricsService.test.ts +++ b/packages/backend/src/services/integrations/GcpMetricsService.test.ts @@ -474,6 +474,14 @@ describe("GcpMetricsService", () => { ["aggregation.groupByFields", "metric.label.response_code_class"], ], ) + // The Compute Engine instance name is a metric label: grouped on, for the resource. + const network = calls.monitoring.find( + (call) => call.metricType === "compute.googleapis.com/instance/network/sent_bytes_count", + )! + assert.include( + network.params.getAll("aggregation.groupByFields"), + "metric.label.instance_name", + ) assert.lengthOf(calls.ingest, 1) assert.match(calls.ingest[0]!.authorization ?? "", /^Bearer maple_pk_/) @@ -493,7 +501,7 @@ describe("GcpMetricsService", () => { "cloud.account.id": "acme-prod", }) const metric = resource.scopeMetrics[0].metrics[0] - assert.strictEqual(metric.name, "gcp.run.request_count") + assert.strictEqual(metric.name, "gcp.cloud_run.requests") assert.strictEqual(metric.sum.aggregationTemporality, 1) assert.deepStrictEqual( metric.sum.dataPoints.map((point: { asDouble: number }) => point.asDouble), diff --git a/packages/backend/src/services/integrations/gcp/api.ts b/packages/backend/src/services/integrations/gcp/api.ts index 98e53c2948..53b62134f1 100644 --- a/packages/backend/src/services/integrations/gcp/api.ts +++ b/packages/backend/src/services/integrations/gcp/api.ts @@ -373,7 +373,9 @@ export const listTimeSeriesPage = ( "aggregation.groupByFields": [ "resource.label.project_id", ...query.group.resourceLabels.map((label) => `resource.label.${label}`), - ...query.metric.labels.map((label) => `metric.label.${label}`), + ...[...query.group.resourceMetricLabels, ...query.metric.labels].map( + (label) => `metric.label.${label}`, + ), ], view: "FULL", pageSize: TIME_SERIES_PAGE_SIZE, diff --git a/packages/backend/src/services/integrations/gcp/mapping.test.ts b/packages/backend/src/services/integrations/gcp/mapping.test.ts index 858a92e55d..a2e2bfb017 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.test.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.test.ts @@ -44,9 +44,9 @@ describe("mapGcpTimeSeries", () => { // The idle minute is not stored. expect(sumRows).toHaveLength(1) expect(sumRows[0]).toMatchObject({ - metric_name: "gcp.run.request_count", + metric_name: "gcp.cloud_run.requests", metric_unit: "{request}", - metric_attributes: { response_code_class: "5xx" }, + metric_attributes: { "gcp.metric.labels.response_code_class": "5xx" }, service_name: "checkout", scope_name: GCP_SCOPE_NAME, value: 7, @@ -73,7 +73,7 @@ describe("mapGcpTimeSeries", () => { ["2026-10-08 12:01:00.000", "2026-10-08 12:01:00.000", 0], ["2026-10-08 12:02:00.000", "2026-10-08 12:02:00.000", 2.5], ]) - expect(gaugeRows[0]!.metric_attributes).toEqual({ state: "idle" }) + expect(gaugeRows[0]!.metric_attributes).toEqual({ "gcp.metric.labels.state": "idle" }) }) it("keeps the points that end inside (start, end]", () => { @@ -90,7 +90,7 @@ describe("mapGcpTimeSeries", () => { expect(gaugeRows.map((row) => row.value)).toEqual([1, 5]) }) - it("turns a distribution into p50/p95/p99 gauges, converting the unit", () => { + it("turns a distribution into p50/p95/p99 gauges, in seconds", () => { const { gaugeRows } = map("cloudfunctions.googleapis.com/function/execution_times", [ { metric: { labels: { status: "ok" } }, @@ -113,18 +113,41 @@ describe("mapGcpTimeSeries", () => { ], }, ]) - expect(gaugeRows.map((row) => [row.metric_attributes, Math.round(row.value)])).toEqual([ - [{ quantile: "0.5" }, 100], - [{ quantile: "0.95" }, 190], - [{ quantile: "0.99" }, 198], + expect(gaugeRows.map((row) => [row.metric_attributes, Number(row.value.toFixed(6))])).toEqual([ + [{ quantile: "0.5" }, 0.1], + [{ quantile: "0.95" }, 0.19], + [{ quantile: "0.99" }, 0.198], ]) expect(gaugeRows[0]).toMatchObject({ - metric_name: "gcp.cloudfunctions.function.execution_times", - metric_unit: "ms", + metric_name: "gcp.cloud_functions.execution.duration", + metric_unit: "s", service_name: "resize", }) }) + it("splits a byte counter by direction, and moves the instance name to the resource", () => { + const series = { + metric: { labels: { instance_name: "web-1" } }, + resource: { + labels: { project_id: "acme-prod", instance_id: "8123456789", zone: "europe-west1-b" }, + }, + points: [{ interval: { endTime: at(2) }, value: { int64Value: "2048" } }], + } + const [received] = map("compute.googleapis.com/instance/network/received_bytes_count", [ + series, + ]).sumRows + const [sent] = map("compute.googleapis.com/instance/network/sent_bytes_count", [series]).sumRows + expect([received, sent].map((row) => [row!.metric_name, row!.metric_attributes])).toEqual([ + ["gcp.gce.network.io", { "network.io.direction": "receive" }], + ["gcp.gce.network.io", { "network.io.direction": "transmit" }], + ]) + expect(received!.resource_attributes).toMatchObject({ + "service.name": "web-1", + "host.name": "web-1", + "gcp.gce.instance.name": "web-1", + }) + }) + it("drops a load balancer series that names no URL map", () => { const series = (url_map_name: string) => ({ metric: { labels: { response_code_class: "200" } }, @@ -220,6 +243,7 @@ describe("gcpResourceAttributes", () => { expect(instance).toMatchObject({ "service.name": "web-1", "host.name": "web-1", + "gcp.gce.instance.name": "web-1", "host.id": "8123456789", "cloud.platform": "gcp_compute_engine", "cloud.availability_zone": "europe-west1-b", diff --git a/packages/backend/src/services/integrations/gcp/mapping.ts b/packages/backend/src/services/integrations/gcp/mapping.ts index 103c84fa62..d272aa75e2 100644 --- a/packages/backend/src/services/integrations/gcp/mapping.ts +++ b/packages/backend/src/services/integrations/gcp/mapping.ts @@ -28,7 +28,10 @@ export interface GcpMetricRows { readonly gaugeRows: Array } -/** `labels` are the series' resource labels: the project plus the group's `resourceLabels`. */ +/** + * `labels` are the series' resource labels: the project plus the group's `resourceLabels`; + * `metricLabels` its metric labels, of which the group's `resourceMetricLabels` are read here. + */ export const gcpResourceAttributes = ( resourceType: string, labels: MetricAttrs, @@ -69,6 +72,7 @@ export const gcpResourceAttributes = ( set("cloud.platform", "gcp_compute_engine") set("host.id", labels.instance_id) set("host.name", workload) + set("gcp.gce.instance.name", workload) } attributes["service.name"] = workload || `gcp/${resourceType}` @@ -171,12 +175,11 @@ export const mapGcpTimeSeries = ( item.resource.labels ?? {}, metricLabels, ) - const attributes = Object.fromEntries( - metric.labels.flatMap((label) => { - const value = metricLabels[label] - return value === undefined ? [] : [[label, value]] - }), - ) + const attributes: MetricAttrs = { ...metric.attributes } + for (const label of metric.labels) { + const value = metricLabels[label] + if (value !== undefined) attributes[`gcp.metric.labels.${label}`] = value + } const row = (endMs: number, value: number, metricAttributes: MetricAttrs): MetricGaugeRow => ({ timestamp: fmtMetricTs(endMs), start_timestamp: fmtMetricTs(metric.kind === "sum" ? endMs - ALIGNMENT_MS : endMs), @@ -205,7 +208,8 @@ export const mapGcpTimeSeries = ( if (metric.kind === "quantiles") { for (const q of QUANTILES) { const value = distributionQuantile(point.value.distributionValue ?? {}, q) - if (value !== undefined) rows.gaugeRows.push(row(endMs, value, { quantile: String(q) })) + if (value !== undefined) + rows.gaugeRows.push(row(endMs, value, { ...attributes, quantile: String(q) })) } continue } diff --git a/packages/domain/src/gcp-metrics.test.ts b/packages/domain/src/gcp-metrics.test.ts index 0358783522..bcd4678129 100644 --- a/packages/domain/src/gcp-metrics.test.ts +++ b/packages/domain/src/gcp-metrics.test.ts @@ -4,17 +4,35 @@ import { GCP_METRIC_GROUPS } from "./gcp-metrics" describe("GCP_METRIC_GROUPS", () => { const metrics = GCP_METRIC_GROUPS.flatMap((group) => group.metrics) - it("names every metric after its Cloud Monitoring type, under gcp.", () => { - const names = Object.fromEntries(metrics.map((metric) => [metric.type, metric.name])) - expect(names).toMatchObject({ - "run.googleapis.com/request_count": "gcp.run.request_count", - "kubernetes.io/container/cpu/core_usage_time": "gcp.kubernetes.container.cpu.core_usage_time", - "cloudsql.googleapis.com/database/postgresql/num_backends": - "gcp.cloudsql.database.postgresql.num_backends", - }) - // No type twice, and no two types under one name. - expect(Object.keys(names)).toHaveLength(metrics.length) - expect(new Set(Object.values(names)).size).toBe(metrics.length) + it("reads every Cloud Monitoring type once", () => { + expect(new Set(metrics.map((metric) => metric.type)).size).toBe(metrics.length) + }) + + it("tells types sharing a name apart by a constant attribute, except the per-engine connection gauges", () => { + const typesByKey = new Map>() + for (const metric of metrics) { + const key = `${metric.name} ${JSON.stringify(metric.attributes)}` + typesByKey.set(key, [...(typesByKey.get(key) ?? []), metric.type]) + } + expect([...typesByKey.values()].filter((types) => types.length > 1)).toEqual([ + [ + "cloudsql.googleapis.com/database/network/connections", + "cloudsql.googleapis.com/database/postgresql/num_backends", + ], + ]) + }) + + it("defines a shared name once, since ingest merges its points into one metric", () => { + const definitions = new Map>() + for (const metric of metrics) { + const definition = JSON.stringify([metric.kind, metric.unit, metric.description]) + definitions.set(metric.name, (definitions.get(metric.name) ?? new Set()).add(definition)) + } + expect([...definitions].filter(([, distinct]) => distinct.size > 1)).toEqual([]) + }) + + it("stores every duration in seconds", () => { + expect(metrics.filter((metric) => ["ms", "us", "ns"].includes(metric.unit))).toEqual([]) }) it("never groups by the project itself: every query adds it", () => { diff --git a/packages/domain/src/gcp-metrics.ts b/packages/domain/src/gcp-metrics.ts index 199cc3fd1d..9a80d8b5af 100644 --- a/packages/domain/src/gcp-metrics.ts +++ b/packages/domain/src/gcp-metrics.ts @@ -4,14 +4,20 @@ * anything that charts Google Cloud metrics reads the Maple names from here. * * Cloud Monitoring aligns every series to 60 seconds and reduces it to the project, the group's - * `resourceLabels` and the metric's `labels`; every other label (revision, pod, response code, - * device) is aggregated away before it reaches Maple, which is what bounds cardinality. + * `resourceLabels` and `resourceMetricLabels`, and the metric's `labels`; every other label + * (revision, pod, response code, device) is aggregated away before it reaches Maple, which is what + * bounds cardinality. + * + * Names follow the OpenTelemetry semantic conventions' naming rules under the `gcp.` vendor + * namespace: durations in seconds, byte counters as `.io` split by the semconv direction attribute. + * Two types can share a name when they differ by a constant attribute, or report the same thing + * for different engines. */ export interface GcpMetric { /** Cloud Monitoring metric type. */ readonly type: string - /** Metric name in Maple: the type under a `gcp.` prefix, e.g. `gcp.run.request_count`. */ + /** Metric name in Maple, e.g. `gcp.cloud_run.request.duration`. */ readonly name: string /** * What Maple stores: `sum` is a delta counter per minute, `gauge` a sampled value, and @@ -23,8 +29,10 @@ export interface GcpMetric { readonly description: string /** How the series a group folds together are combined. */ readonly reducer: "REDUCE_SUM" | "REDUCE_MEAN" - /** Metric labels kept as attributes under their Cloud Monitoring names. */ + /** Metric labels kept on every point as `gcp.metric.labels.