Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cd01865
feat(integrations): Google Cloud metrics and resource poller
JeremyFunk Oct 8, 2026
6bc5f31
Merge remote-tracking branch 'origin/feat/gcp-connector-api' into fea…
JeremyFunk Oct 8, 2026
4fbb5e2
feat(integrations): expose Google Cloud poll state on the connector, …
JeremyFunk Oct 8, 2026
d722c63
Merge remote-tracking branch 'origin/feat/gcp-connector-api' into fea…
JeremyFunk Oct 8, 2026
de8050d
fix(integrations): tighten the Google Cloud poller's window, budget a…
JeremyFunk Oct 8, 2026
e179356
Merge remote-tracking branch 'origin/feat/gcp-connector-api' into fea…
JeremyFunk Oct 8, 2026
0118ca9
Merge remote-tracking branch 'origin/feat/gcp-log-push-ingest' into f…
JeremyFunk Oct 8, 2026
c3a79cf
fix(integrations): read Cloud Monitoring point times with timestampMs
JeremyFunk Oct 8, 2026
8adfb00
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 8, 2026
66cf416
fix(integrations): show a Pub/Sub resource by its id, not its full path
JeremyFunk Oct 8, 2026
f79b7d6
fix(integrations): say so when Google Cloud refuses a read over billi…
JeremyFunk Oct 8, 2026
972e103
fix(integrations): wait for the first metrics again when Google Cloud…
JeremyFunk Oct 8, 2026
df95f82
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
7e755cb
feat(integrations): Google Cloud metrics errors that name the cause a…
JeremyFunk Oct 9, 2026
8ad792b
fix(integrations): Google Cloud metrics error texts that hold for eve…
JeremyFunk Oct 9, 2026
e204fe3
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
a290b75
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
8ea5990
fix(integrations): Google Cloud percentiles below the first bucket, n…
JeremyFunk Oct 9, 2026
926f6cb
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
2de6718
Merge feat/gcp-log-push-ingest into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
dbccc76
fix(integrations): Google Cloud poller texts by history and scope
JeremyFunk Oct 9, 2026
25b7d7a
Merge feat/gcp-log-push-ingest into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
ff890e7
Merge feat/gcp-log-push-ingest into feat/gcp-metrics-poller
JeremyFunk Oct 9, 2026
187a687
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 10, 2026
44c15f1
refactor(integrations): Google Cloud poller and inventory sync on eff…
JeremyFunk Oct 10, 2026
7bb0c95
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 14, 2026
e327404
test(gcp): follow effect 4.0.4's opt-in header capture, 4xx client sp…
JeremyFunk Oct 14, 2026
7083903
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 14, 2026
deda04b
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 15, 2026
a70df57
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 15, 2026
250a052
chore(db): regenerate the gcp_metrics_poller migration after main's n…
JeremyFunk Oct 15, 2026
abb3fa2
refactor(gcp): semconv-style metric names and attributes
JeremyFunk Oct 15, 2026
2e09878
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 15, 2026
ebf8de6
Merge remote-tracking branch 'origin/feat/gcp-log-push-ingest' into HEAD
JeremyFunk Oct 15, 2026
6185ec1
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 15, 2026
c8411e7
Merge remote-tracking branch 'origin/feat/gcp-log-push-ingest' into HEAD
JeremyFunk Oct 15, 2026
c12c51c
fix(ci): resolve the bundle budget merge
JeremyFunk Oct 15, 2026
fca89dd
feat(gcp): poll Cloud SQL health, Query Insights and instance settings
JeremyFunk Oct 15, 2026
77964ac
Merge branch 'feat/gcp-log-push-ingest' into feat/gcp-metrics-poller
JeremyFunk Oct 16, 2026
1dab24e
Merge feat/gcp-log-push-ingest into feat/gcp-metrics-poller
JeremyFunk Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,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
Expand Down
9 changes: 8 additions & 1 deletion apps/alerting/src/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ export const ALERTING_SCHEDULE = {
// evaluates, so a one-minute bound would starve the drain on a slow minute.
"* * * * *": { lanes: ["alert", "error", "escalation"], deadline: "110 seconds" },
"*/5 * * * *": {
lanes: ["anomaly", "cloudflare_analytics", "planetscale", "railway_metrics", "pr_review_post_merge"],
lanes: [
"anomaly",
"cloudflare_analytics",
"gcp_metrics",
"planetscale",
"railway_metrics",
"pr_review_post_merge",
],
deadline: "270 seconds",
},
"*/15 * * * *": { lanes: ["digest"], deadline: "10 minutes" },
Expand Down
13 changes: 12 additions & 1 deletion apps/alerting/src/scheduled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { scheduleFor } from "./schedule"
import { layerTick, catchTickFailure, selectLaneProgram, 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"]],
Expand All @@ -26,6 +36,7 @@ const recordingTicks = (
error: tick("error"),
escalation: tick("escalation"),
fixVerification: tick("fixVerification"),
gcpMetrics: tick("gcpMetrics"),
planetScale: tick("planetScale"),
prReviewPostMerge: tick("prReviewPostMerge"),
railwayMetrics: tick("railwayMetrics"),
Expand Down
22 changes: 22 additions & 0 deletions apps/alerting/src/scheduled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { DatabasePgLive } from "@maple/backend/platform/DatabasePgLive"
import { PullRequestLookupLive } from "@maple/backend/services/errors/pull-request-lookup-live"
Expand Down Expand Up @@ -49,6 +50,7 @@ export const layerTick = (
AlertsService.layer,
AnomalyDetectionService.layer,
CloudflareAnalyticsService.layer,
GcpMetricsService.layer,
PlanetScaleService.layer,
RailwayMetricsService.layer,
DigestService.layer,
Expand Down Expand Up @@ -251,6 +253,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",
Expand Down Expand Up @@ -303,6 +321,7 @@ export interface ScheduledTickPrograms<R = never> {
readonly error: Effect.Effect<void, never, R>
readonly escalation: Effect.Effect<void, never, R>
readonly fixVerification: Effect.Effect<void, never, R>
readonly gcpMetrics: Effect.Effect<void, never, R>
readonly planetScale: Effect.Effect<void, never, R>
readonly prReviewPostMerge: Effect.Effect<void, never, R>
readonly railwayMetrics: Effect.Effect<void, never, R>
Expand All @@ -320,6 +339,7 @@ const laneProgram = <R>(lane: TickLane, ticks: ScheduledTickPrograms<R>): Effect
Match.when("escalation", () => ticks.escalation),
Match.when("anomaly", () => ticks.anomaly),
Match.when("cloudflare_analytics", () => ticks.cloudflareAnalytics),
Match.when("gcp_metrics", () => ticks.gcpMetrics),
Match.when("planetscale", () => ticks.planetScale),
Match.when("railway_metrics", () => ticks.railwayMetrics),
Match.when("pr_review_post_merge", () => ticks.prReviewPostMerge),
Expand Down Expand Up @@ -369,6 +389,7 @@ type ScheduledServices =
| ErrorsService
| EscalationService
| FixVerificationTickService
| GcpMetricsService
| PlanetScaleService
| PrReviewPostMergeService
| RailwayMetricsService
Expand All @@ -382,6 +403,7 @@ export const scheduledTicks: ScheduledTickPrograms<ScheduledServices> = {
error: errorTick,
escalation: escalationTick,
fixVerification: fixVerificationTick,
gcpMetrics: gcpMetricsTick,
planetScale: planetScaleTick,
prReviewPostMerge: prReviewPostMergeTick,
railwayMetrics: railwayMetricsTick,
Expand Down
2 changes: 2 additions & 0 deletions apps/alerting/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,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"),
// Ticks post pull-request replies through the GitHub App; without it each one fails.
githubAppSourceEnv,
// Outbound config for `chat` destinations' connectors.
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/integrations-gcp.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ describe("v2 gcp integration over HTTP", () => {
applied_logs_enabled: null,
applied_metrics_enabled: null,
setup_reported_at: 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.
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/v2/integrations-gcp.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const toV2Connector = (connector: GcpConnector): V2GcpConnector => ({
applied_logs_enabled: connector.appliedLogsEnabled,
applied_metrics_enabled: connector.appliedMetricsEnabled,
setup_reported_at: isoTimestampOrNull(connector.setupReportedAt),
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) => ({
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/platform/Env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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<string>
/**
* 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<Redacted.Redacted<string>>
readonly CLOUDFLARE_OAUTH_CLIENT_ID: Option.Option<string>
readonly CLOUDFLARE_OAUTH_CLIENT_SECRET: Option.Option<Redacted.Redacted<string>>
readonly CLOUDFLARE_OAUTH_SCOPES: string
Expand Down Expand Up @@ -260,6 +265,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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,97 @@ describe("GcpConnectorService", () => {
}).pipe(Effect.provide(makeLayer(testDb)))
})

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
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])

// 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)
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* () {
Expand Down
43 changes: 39 additions & 4 deletions packages/backend/src/services/integrations/GcpConnectorService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { randomBytes, randomUUID } from "node:crypto"
import * as PG from "@maple-dev/effect-orm/postgres"
import { hashIngestKey, parseIngestKeyLookupHmacKey } from "@maple/db"
import { GcpConnectors, type GcpConnectorRow } from "@maple/db/tables"
import { GcpConnectors, GcpResources, type GcpConnectorRow } from "@maple/db/tables"
import { GCP_PROJECT_ASSET_TYPE } from "@maple/domain/gcp-metrics"
import {
GcpMetricsUnavailableError,
GcpScopeAlreadyConnectedError,
Expand Down Expand Up @@ -43,6 +44,12 @@ export interface GcpConnector {
readonly appliedLogsEnabled: boolean | null
readonly appliedMetricsEnabled: boolean | null
readonly setupReportedAt: number | 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<
Expand Down Expand Up @@ -104,7 +111,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,
Expand All @@ -117,6 +124,10 @@ const toConnector = (row: GcpConnectorRow): GcpConnector => ({
appliedLogsEnabled: row.appliedLogsEnabled,
appliedMetricsEnabled: row.appliedMetricsEnabled,
setupReportedAt: row.setupReportedAt,
lastMetricsReceivedAt: row.lastMetricsReceivedAt,
lastMetricsError: row.lastMetricsError,
discoveredProjectCount,
lastResourcesError: row.lastResourcesError,
})

const notFound = () => new IntegrationsNotFoundError({ message: "No such Google Cloud connector." })
Expand Down Expand Up @@ -173,6 +184,20 @@ export class GcpConnectorService extends Context.Service<GcpConnectorService, Gc
),
)

/** Projects in each connector's inventory. One the poller has not synced yet is absent. */
const projectCounts = (connectorIds: ReadonlyArray<GcpConnectorId>) =>
dbExecute((db) =>
db.run(
PG.from(GcpResources)
.select(($) => ({ connectorId: $.connectorId, count: PG.count() }))
.where(($) => [
$.connectorId.in_(...connectorIds),
$.assetType.eq(GCP_PROJECT_ASSET_TYPE),
])
.groupBy("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) =>
Expand All @@ -183,9 +208,14 @@ export class GcpConnectorService extends Context.Service<GcpConnectorService, Gc
.orderBy(["createdAt", "asc"], ["id", "asc"]),
),
)
// No connectors, nothing to count.
const counts =
rows.length === 0
? new Map<GcpConnectorId, number>()
: yield* projectCounts(rows.map((row) => row.id))
return {
metricsAvailable: mapleServiceAccountEmail !== undefined,
connectors: rows.map(toConnector),
connectors: rows.map((row) => toConnector(row, counts.get(row.id))),
}
})

Expand Down Expand Up @@ -273,6 +303,10 @@ export class GcpConnectorService extends Context.Service<GcpConnectorService, Gc
...(flags.logsEnabled && !current.logsEnabled
? { lastReceivedAt: null, lastError: null }
: undefined),
// Only what the card shows: the poller's watermark and lease stay.
...(flags.metricsEnabled && !current.metricsEnabled
? { lastMetricsReceivedAt: null, lastMetricsError: null }
: undefined),
}
if (invalid === undefined) {
yield* db.run(
Expand All @@ -287,7 +321,8 @@ export class GcpConnectorService extends Context.Service<GcpConnectorService, Gc
)
if (outcome === undefined) return yield* notFound()
if (outcome.invalid !== undefined) return yield* Effect.fail(outcome.invalid)
return toConnector(outcome.row)
const { row } = outcome
return toConnector(row, (yield* projectCounts([row.id])).get(row.id))
})

const scripts = Effect.fn("GcpConnectorService.scripts")(function* (
Expand Down
Loading
Loading