diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 10cfc9ad3..dc3aefd53 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -27,7 +27,6 @@ import { HttpV2SessionReplaysLive } from "./routes/v2/session-replays.http" import { HttpV2LogsLive, HttpV2MetricsLive, - HttpV2QueryLive, HttpV2ServiceMapLive, HttpV2ServicesLive, HttpV2TracesLive, @@ -322,7 +321,6 @@ const ApiV2Routes = HttpApiBuilder.layer(MapleApiV2).pipe( HttpV2MetricsLive, HttpV2ServicesLive, HttpV2ServiceMapLive, - HttpV2QueryLive, ), ), Layer.provide(V2SchemaErrorsLive), diff --git a/apps/api/src/routes/v2/alchemy-provider.integration.test.ts b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts new file mode 100644 index 000000000..9f8a38ea8 --- /dev/null +++ b/apps/api/src/routes/v2/alchemy-provider.integration.test.ts @@ -0,0 +1,432 @@ +/** + * Integration test for the `@maple-dev/alchemy` provider package + * (`lib/alchemy-maple`): drives the real provider lifecycle functions + * (reconcile / read / delete) through the real v2 handlers over PGlite, + * with the package's own HTTP client routed at the in-process web handler + * via a custom `fetch`. + */ +import { afterEach, describe, expect, it } from "@effect/vitest" +import { ConfigProvider, Context, Effect, Layer, ManagedRuntime, Redacted, Schema } from "effect" +import { FetchHttpClient, HttpRouter } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { OrgId, UserId } from "@maple/domain/http" +import { MapleApiV2 } from "@maple/domain/http/v2" +import { BucketCacheService, EdgeCacheService } from "@maple/query-engine/caching" +import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli" +import { AlertDestination, AlertDestinationProvider } from "../../../../../lib/alchemy-maple/src/AlertDestination.ts" +import { AlertRule, AlertRuleProvider } from "../../../../../lib/alchemy-maple/src/AlertRule.ts" +import { ApiKey, ApiKeyProvider } from "../../../../../lib/alchemy-maple/src/ApiKey.ts" +import { Dashboard, DashboardProvider } from "../../../../../lib/alchemy-maple/src/Dashboard.ts" +import { make as makeMapleApi, MapleApi } from "../../../../../lib/alchemy-maple/src/MapleApi.ts" +import { MapleEnvironment } from "../../../../../lib/alchemy-maple/src/MapleEnvironment.ts" +import { CacheBackendLive } from "../../lib/CacheBackendLive" +import { EmailService } from "../../lib/EmailService" +import { Env } from "../../lib/Env" +import { cleanupTestDbs, createTestDb, type TestDb } from "../../lib/test-pglite" +import type { WarehouseQueryServiceShape } from "../../lib/WarehouseQueryService" +import { WarehouseQueryService } from "../../lib/WarehouseQueryService" +import { ApiAuthorizationV2Layer } from "../../services/ApiAuthorizationV2Layer" +import { ApiKeysService } from "../../services/ApiKeysService" +import { AuthService } from "../../services/AuthService" +import { DashboardPersistenceService } from "../../services/DashboardPersistenceService" +import { AlertRuntime, AlertsService } from "../../services/AlertsService" +import { HazelOAuthService } from "../../services/HazelOAuthService" +import { OrgMembersService } from "../../services/OrgMembersService" +import { QueryEngineService } from "../../services/QueryEngineService" +import { V2SchemaErrorsLive } from "./error-envelope" +import { + AllV2GroupLayersLive, + ApiV2RateLimiterAllowAllLayer, + ConfigResourceServiceStubsLayer, + TelemetryServiceStubsLayer, +} from "./v2-test-support" + +const createdDbs: TestDb[] = [] +afterEach(() => cleanupTestDbs(createdDbs)) + +const testConfig = () => + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + PORT: "3486", + MCP_PORT: "3487", + 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, 1).toString("base64"), + MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY: "maple-test-lookup-secret", + MAPLE_APP_BASE_URL: "http://127.0.0.1:3471", + INTERNAL_SERVICE_TOKEN: "test-internal-token", + QE_EVAL_BUCKET_CACHE_ENABLED: "false", + }), + ) + +/** The v2 CRUD endpoints exercised here never reach the warehouse. */ +const warehouseStub: WarehouseQueryServiceShape = { + query: () => Effect.die(new Error("unexpected warehouse pipe query")), + sqlQuery: () => Effect.succeed([]), + rawSqlQuery: () => Effect.succeed([]), + compiledQuery: (_tenant, compiled) => compiled.decodeRows([]).pipe(Effect.orDie), + compiledQueryFirst: () => Effect.die(new Error("unexpected compiled query")), + ingest: () => Effect.void, + asExecutor: () => { + throw new Error("asExecutor is not supported by this test stub") + }, +} + +const session: ScopedPlanStatusSession = { + emit: () => Effect.void, + done: () => Effect.void, + note: () => Effect.void, +} + +const makeHarness = () => { + const testDb = createTestDb(createdDbs) + const configLive = testConfig() + const envLive = Env.layer.pipe(Layer.provide(configLive)) + const warehouseLive = Layer.succeed(WarehouseQueryService, warehouseStub) + const edgeCacheLive = EdgeCacheService.layer.pipe(Layer.provide(CacheBackendLive)) + const bucketCacheLive = BucketCacheService.layer.pipe(Layer.provide(edgeCacheLive)) + const queryEngineLive = QueryEngineService.layer.pipe( + Layer.provide(warehouseLive), + Layer.provide(edgeCacheLive), + Layer.provide(bucketCacheLive), + Layer.provide(configLive), + ) + const runtimeLive = Layer.succeed(AlertRuntime, { + now: Effect.sync(() => Date.now()), + makeUuid: () => crypto.randomUUID(), + fetch: globalThis.fetch, + deliveryTimeoutMs: () => 15_000, + }) + const hazelOAuthLive = HazelOAuthService.layer.pipe(Layer.provide(Layer.mergeAll(envLive, testDb.layer))) + const emailLive = Layer.succeed(EmailService, { + isConfigured: false, + send: () => Effect.void, + }) + const orgMembersLive = Layer.succeed(OrgMembersService, { + resolveMembers: () => Effect.succeed([]), + }) + const alertsLive = AlertsService.layer.pipe( + Layer.provide( + Layer.mergeAll( + envLive, + testDb.layer, + queryEngineLive, + warehouseLive, + runtimeLive, + hazelOAuthLive, + emailLive, + orgMembersLive, + ), + ), + ) + const servicesLive = Layer.mergeAll( + ApiKeysService.layer, + AuthService.layer, + DashboardPersistenceService.layer, + alertsLive, + ).pipe(Layer.provideMerge(Layer.mergeAll(envLive, testDb.layer))) + + const routes = HttpApiBuilder.layer(MapleApiV2).pipe( + Layer.provide(AllV2GroupLayersLive), + Layer.provide(ConfigResourceServiceStubsLayer), + Layer.provide(TelemetryServiceStubsLayer), + Layer.provide(V2SchemaErrorsLive), + Layer.provideMerge(ApiAuthorizationV2Layer), + Layer.provideMerge(ApiV2RateLimiterAllowAllLayer), + Layer.provideMerge(servicesLive), + ) + const { handler, dispose: disposeHandler } = HttpRouter.toWebHandler(routes, { + disableLogger: true, + }) + const runtime = ManagedRuntime.make(servicesLive) + const ORG = Schema.decodeUnknownSync(OrgId)("org_alchemy_e2e") + const USER = Schema.decodeUnknownSync(UserId)("user_alchemy_e2e") + + const bootstrapKey = () => + runtime.runPromise( + Effect.gen(function* () { + const service = yield* ApiKeysService + // No scopes = legacy full access; API-key tenants carry the root role. + return yield* service.create(ORG, USER, { name: "alchemy-provider-test" }) + }), + ) + + /** Route the provider package's HTTP client at the in-process handler. */ + const providerLayers = (secret: string) => { + const fetchToHandler = ((input: RequestInfo | URL, init?: RequestInit) => + handler(new Request(input, init), Context.empty() as never)) as typeof globalThis.fetch + const clientLive = Layer.effect(MapleApi, makeMapleApi).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetchToHandler)), + Layer.provide( + Layer.succeed(MapleEnvironment, { + baseUrl: "http://maple.test", + apiKey: Redacted.make(secret), + }), + ), + ) + return Layer.mergeAll( + DashboardProvider(), + AlertDestinationProvider(), + AlertRuleProvider(), + ApiKeyProvider(), + ).pipe(Layer.provideMerge(clientLive)) + } + + return { + bootstrapKey, + providerLayers, + dispose: async () => { + await disposeHandler() + await runtime.dispose() + }, + } +} + +describe("@maple-dev/alchemy providers against the real v2 handlers", () => { + it("runs the full dashboard lifecycle: create → noop → drift patch → delete", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + const layers = harness.providerLayers(key.secret) + + await Effect.runPromise( + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + + // Create. + const created = yield* provider.reconcile({ + id: "ops", + instanceId: "i-1", + news: { name: "Operations", tags: ["production"] }, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + expect(created.dashboardId).toMatch(/^dash_/) + + // Steady state: no drift, no mutation (updated name unchanged). + const steady = yield* provider.reconcile({ + id: "ops", + instanceId: "i-1", + news: { name: "Operations", tags: ["production"] }, + olds: { name: "Operations", tags: ["production"] }, + output: created, + session, + bindings: [], + }) + expect(steady.dashboardId).toBe(created.dashboardId) + + // Drift: rename via PATCH, id stable. + const renamed = yield* provider.reconcile({ + id: "ops", + instanceId: "i-1", + news: { name: "Operations v2", tags: ["production"] }, + olds: { name: "Operations", tags: ["production"] }, + output: created, + session, + bindings: [], + }) + expect(renamed.dashboardId).toBe(created.dashboardId) + expect(renamed.name).toBe("Operations v2") + + // Read observes the renamed dashboard. + const observed = yield* provider.read!({ + id: "ops", + instanceId: "i-1", + olds: { name: "Operations v2" }, + output: renamed, + }) + expect(observed?.name).toBe("Operations v2") + + // Delete, then read sees nothing; second delete tolerates the 404. + yield* provider.delete({ + id: "ops", + instanceId: "i-1", + olds: { name: "Operations v2" }, + output: renamed, + session, + bindings: [], + }) + const gone = yield* provider.read!({ + id: "ops", + instanceId: "i-1", + olds: { name: "Operations v2" }, + output: renamed, + }) + expect(gone).toBeUndefined() + yield* provider.delete({ + id: "ops", + instanceId: "i-1", + olds: { name: "Operations v2" }, + output: renamed, + session, + bindings: [], + }) + }).pipe(Effect.provide(layers)) as Effect.Effect, + ) + await harness.dispose() + }) + + it("wires destination → rule, adopts rules by unique name, and deletes cleanly", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + const layers = harness.providerLayers(key.secret) + + await Effect.runPromise( + Effect.gen(function* () { + const destinations = yield* AlertDestination.Provider + const rules = yield* AlertRule.Provider + + const dest = yield* destinations.reconcile({ + id: "hook", + instanceId: "i-1", + news: { type: "webhook", name: "Ops hook", url: "https://example.com/hooks/maple" }, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + expect(dest.destinationId).toMatch(/^dest_/) + + const ruleProps = { + name: "Checkout error rate", + severity: "critical" as const, + signal_type: "error_rate" as const, + comparator: "gt" as const, + threshold: 0.05, + window_minutes: 5, + destination_ids: [dest.destinationId], + } + const rule = yield* rules.reconcile({ + id: "checkout-errors", + instanceId: "i-1", + news: ruleProps, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + expect(rule.ruleId).toMatch(/^alrt_/) + + // Lost state (output undefined) → adopted by org-unique name, not duplicated. + const adopted = yield* rules.reconcile({ + id: "checkout-errors", + instanceId: "i-1", + news: ruleProps, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + expect(adopted.ruleId).toBe(rule.ruleId) + + // Update threshold via PATCH. + const updated = yield* rules.reconcile({ + id: "checkout-errors", + instanceId: "i-1", + news: { ...ruleProps, threshold: 0.1 }, + olds: ruleProps, + output: rule, + session, + bindings: [], + }) + expect(updated.ruleId).toBe(rule.ruleId) + + // Delete rule first (destination delete conflicts while referenced). + yield* rules.delete({ + id: "checkout-errors", + instanceId: "i-1", + olds: ruleProps, + output: updated, + session, + bindings: [], + }) + yield* destinations.delete({ + id: "hook", + instanceId: "i-1", + news: undefined, + olds: { type: "webhook", name: "Ops hook", url: "https://example.com/hooks/maple" }, + output: dest, + session, + bindings: [], + }) + const goneRules = yield* rules.list() + expect(goneRules).toHaveLength(0) + }).pipe(Effect.provide(layers)) as Effect.Effect, + ) + await harness.dispose() + }) + + it("creates an API key with a one-time secret, preserves it, rolls, and revokes", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey() + const layers = harness.providerLayers(key.secret) + + await Effect.runPromise( + Effect.gen(function* () { + const provider = yield* ApiKey.Provider + + const created = yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci-pipeline", scopes: ["dashboards:write"] }, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + expect(created.keyId).toMatch(/^key_/) + expect(Redacted.value(created.secret)).toMatch(/^maple_ak_/) + + // Steady state preserves the secret (the API never returns it again). + const steady = yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci-pipeline", scopes: ["dashboards:write"] }, + olds: { name: "ci-pipeline", scopes: ["dashboards:write"] }, + output: created, + session, + bindings: [], + }) + expect(steady.keyId).toBe(created.keyId) + expect(Redacted.value(steady.secret)).toBe(Redacted.value(created.secret)) + + // Rotate bump → roll: new id + secret, same name. + const rolled = yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci-pipeline", scopes: ["dashboards:write"], rotate: 1 }, + olds: { name: "ci-pipeline", scopes: ["dashboards:write"] }, + output: steady, + session, + bindings: [], + }) + expect(rolled.keyId).not.toBe(created.keyId) + expect(Redacted.value(rolled.secret)).not.toBe(Redacted.value(created.secret)) + expect(rolled.name).toBe("ci-pipeline") + + // Revoke; read reports it gone. + yield* provider.delete({ + id: "ci", + instanceId: "i-1", + olds: { name: "ci-pipeline", scopes: ["dashboards:write"], rotate: 1 }, + output: rolled, + session, + bindings: [], + }) + const gone = yield* provider.read!({ + id: "ci", + instanceId: "i-1", + olds: { name: "ci-pipeline", scopes: ["dashboards:write"], rotate: 1 }, + output: rolled, + }) + expect(gone).toBeUndefined() + }).pipe(Effect.provide(layers)) as Effect.Effect, + ) + await harness.dispose() + }) +}) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 4cc278498..f2d9738f3 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -25,6 +25,7 @@ const TRACE_ID = "7f3a4b5c6d7e8f901234567890abcdef" const SPAN_ID = "0123456789abcdef" const START = "2026-07-15T12:00:00.000Z" const END = "2026-07-15T13:00:00.000Z" +const allowedWindow = () => ({ start_time: START, end_time: END }) const windowQuery = `start_time=${encodeURIComponent(START)}&end_time=${encodeURIComponent(END)}` const createdDbs: TestDb[] = [] @@ -184,16 +185,28 @@ const warehouseStub: WarehouseQueryServiceShape = { } const queryEngineStub = { - execute: () => - Effect.succeed( + execute: (_tenant, request) => { + if (request.query.kind === "breakdown") { + return Effect.succeed( + new QueryEngineExecuteResponse({ + result: { + kind: "breakdown", + source: request.query.source, + data: [{ name: "api", value: 42 }], + }, + }), + ) + } + return Effect.succeed( new QueryEngineExecuteResponse({ result: { kind: "timeseries", - source: "metrics", + source: request.query.source, data: [{ bucket: "2026-07-15 12:00:00", series: { all: 42 } }], }, }), - ), + ) + }, evaluate: () => Effect.die(new Error("not used")), evaluateRawSql: () => Effect.die(new Error("not used")), evaluateSeries: () => Effect.die(new Error("not used")), @@ -262,14 +275,29 @@ const makeHarness = ( } describe("v2 telemetry reads over HTTP", () => { - it("serves all trace, log, metric, service, service-map, and query reads", async () => { + it("serves trace, log, metric, service, and service-map reads", async () => { const harness = makeHarness() const key = await harness.bootstrapKey() const bounds = { start_time: START, end_time: END } const traces = await harness.request("POST", "/v2/traces/search", key.secret, bounds) expect(traces.status).toBe(200) - expect(traces.body.data[0]).toMatchObject({ object: "trace", id: TRACE_ID, has_error: true }) + expect(traces.body.data[0]).toMatchObject({ + object: "trace", + id: TRACE_ID, + root_has_error: true, + }) + const traceSeries = await harness.request("POST", "/v2/traces/timeseries", key.secret, { + ...bounds, + aggregation: "count", + }) + expect(traceSeries.body).toMatchObject({ object: "trace_timeseries", aggregation: "count" }) + const traceBreakdown = await harness.request("POST", "/v2/traces/breakdown", key.secret, { + ...bounds, + aggregation: "count", + group_by: "service", + }) + expect(traceBreakdown.body.data[0]).toEqual({ name: "api", value: 42 }) const trace = await harness.request("GET", `/v2/traces/${TRACE_ID}`, key.secret) expect(trace.status).toBe(200) @@ -286,6 +314,17 @@ describe("v2 telemetry reads over HTTP", () => { const log = await harness.request("GET", `/v2/logs/${logs.body.data[0].id}`, key.secret) expect(log.status).toBe(200) expect(log.body.trace_id).toBe(TRACE_ID) + const logSeries = await harness.request("POST", "/v2/logs/timeseries", key.secret, { + ...bounds, + aggregation: "count", + }) + expect(logSeries.body.object).toBe("log_timeseries") + const logBreakdown = await harness.request("POST", "/v2/logs/breakdown", key.secret, { + ...bounds, + aggregation: "count", + group_by: "service", + }) + expect(logBreakdown.body.object).toBe("log_breakdown") const metrics = await harness.request("GET", `/v2/metrics?${windowQuery}`, key.secret) expect(metrics.status).toBe(200) @@ -293,12 +332,21 @@ describe("v2 telemetry reads over HTTP", () => { const metricSeries = await harness.request("POST", "/v2/metrics/timeseries", key.secret, { ...bounds, - metric: "avg", - metric_name: "http.server.duration", - metric_type: "histogram", + aggregation: "avg", + filters: { metric_name: "http.server.duration", metric_type: "histogram" }, }) expect(metricSeries.status).toBe(200) - expect(metricSeries.body.timeseries[0].series.all).toBe(42) + expect(metricSeries.body.series[0]).toEqual({ + group: null, + points: [{ timestamp: START, value: 42 }], + }) + const metricBreakdown = await harness.request("POST", "/v2/metrics/breakdown", key.secret, { + ...bounds, + aggregation: "avg", + group_by: "service", + filters: { metric_name: "http.server.duration", metric_type: "histogram" }, + }) + expect(metricBreakdown.body.object).toBe("metric_breakdown") const services = await harness.request("GET", `/v2/services?${windowQuery}`, key.secret) expect(services.status).toBe(200) @@ -310,17 +358,6 @@ describe("v2 telemetry reads over HTTP", () => { expect(serviceMap.status).toBe(200) expect(serviceMap.body.edges[0]).toMatchObject({ source_service: "api", target_service: "payments" }) - const structured = await harness.request("POST", "/v2/query", key.secret, { - ...bounds, - query: { - kind: "timeseries", - source: "metrics", - metric: "avg", - filters: { metric_name: "http.server.duration", metric_type: "histogram" }, - }, - }) - expect(structured.status).toBe(200) - await harness.dispose() }) @@ -332,11 +369,47 @@ describe("v2 telemetry reads over HTTP", () => { end_time: END, }) expect(allowed.status).toBe(200) + for (const [path, body] of [ + ["/v2/traces/timeseries", { ...allowedWindow(), aggregation: "count" }], + ["/v2/traces/breakdown", { ...allowedWindow(), aggregation: "count", group_by: "service" }], + ] as const) { + expect((await harness.request("POST", path, tracesKey.secret, body)).status).toBe(200) + } const denied = await harness.request("POST", "/v2/logs/search", tracesKey.secret, { start_time: START, end_time: END, }) expect(denied.status).toBe(403) + const logsKey = await harness.bootstrapKey(["logs:read"]) + for (const [path, body] of [ + ["/v2/logs/search", allowedWindow()], + ["/v2/logs/timeseries", { ...allowedWindow(), aggregation: "count" }], + ["/v2/logs/breakdown", { ...allowedWindow(), aggregation: "count", group_by: "service" }], + ] as const) { + expect((await harness.request("POST", path, logsKey.secret, body)).status).toBe(200) + } + const metricsKey = await harness.bootstrapKey(["metrics:read"]) + for (const [path, body] of [ + [ + "/v2/metrics/timeseries", + { + ...allowedWindow(), + aggregation: "avg", + filters: { metric_name: "http.server.duration", metric_type: "histogram" }, + }, + ], + [ + "/v2/metrics/breakdown", + { + ...allowedWindow(), + aggregation: "avg", + group_by: "service", + filters: { metric_name: "http.server.duration", metric_type: "histogram" }, + }, + ], + ] as const) { + expect((await harness.request("POST", path, metricsKey.secret, body)).status).toBe(200) + } const missingWindow = await harness.request("POST", "/v2/traces/search", tracesKey.secret, {}) expect(missingWindow.status).toBe(400) const firstPage = await harness.request("POST", "/v2/traces/search", tracesKey.secret, { @@ -367,12 +440,12 @@ describe("v2 telemetry reads over HTTP", () => { const rootKey = await harness.bootstrapKey() const malformedLog = await harness.request("GET", "/v2/logs/log_bad", rootKey.secret) expect(malformedLog.status).toBe(400) - const rawSql = await harness.request("POST", "/v2/query", rootKey.secret, { + const removedQuery = await harness.request("POST", "/v2/query", rootKey.secret, { start_time: START, end_time: END, query: { kind: "raw_sql", sql: "SELECT * FROM traces WHERE $__orgFilter" }, }) - expect(rawSql.status).toBe(400) + expect(removedQuery.status).toBe(404) await harness.dispose() }) @@ -412,48 +485,132 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) + it("enforces signal query windows, bucket budgets, and breakdown narrowing", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["traces:read"]) + const searchTooWide = await harness.request("POST", "/v2/traces/search", key.secret, { + start_time: "2026-07-01T00:00:00.000Z", + end_time: "2026-07-09T00:00:00.000Z", + }) + expect(searchTooWide.body.error.code).toBe("time_range_too_large") + + const timeseriesTooWide = await harness.request("POST", "/v2/traces/timeseries", key.secret, { + start_time: "2026-06-01T00:00:00.000Z", + end_time: "2026-07-03T00:00:00.000Z", + aggregation: "count", + }) + expect(timeseriesTooWide.body.error.code).toBe("time_range_too_large") + const tooManyBuckets = await harness.request("POST", "/v2/traces/timeseries", key.secret, { + ...allowedWindow(), + aggregation: "count", + bucket_seconds: 1, + }) + expect(tooManyBuckets.body.error).toMatchObject({ + code: "bucket_count_too_large", + param: "bucket_seconds", + }) + + const wideBreakdown = { + start_time: "2026-07-01T00:00:00.000Z", + end_time: "2026-07-02T01:00:00.000Z", + aggregation: "count", + group_by: "service", + } + const unfiltered = await harness.request("POST", "/v2/traces/breakdown", key.secret, wideBreakdown) + expect(unfiltered.body.error).toMatchObject({ + code: "breakdown_filter_required", + param: "filters", + }) + const filtered = await harness.request("POST", "/v2/traces/breakdown", key.secret, { + ...wideBreakdown, + filters: { service_name: "api" }, + }) + expect(filtered.status).toBe(200) + const breakdownTooWide = await harness.request("POST", "/v2/traces/breakdown", key.secret, { + ...wideBreakdown, + start_time: "2026-06-01T00:00:00.000Z", + end_time: "2026-07-02T00:00:00.000Z", + filters: { service_name: "api" }, + }) + expect(breakdownTooWide.body.error.code).toBe("time_range_too_large") + await harness.dispose() + }) + it("maps public trace attribute grouping onto the validated internal query", async () => { let observedRequest: QueryEngineExecuteRequest | undefined const queryEngine: QueryEngineServiceShape = { ...queryEngineStub, - execute: (_tenant, request) => { + execute: (tenant, request) => { observedRequest = request - return queryEngineStub.execute() + return queryEngineStub.execute(tenant, request) }, } const harness = makeHarness(warehouseStub, queryEngine) const key = await harness.bootstrapKey() - const response = await harness.request("POST", "/v2/query", key.secret, { + const response = await harness.request("POST", "/v2/traces/timeseries", key.secret, { start_time: START, end_time: END, - query: { - kind: "timeseries", - source: "traces", - metric: "count", - group_by: ["attribute"], - filters: { group_by_attribute_keys: ["http.route"] }, - }, + aggregation: "count", + group_by: "attribute", + group_by_attribute_key: "http.route", }) expect(response.status).toBe(200) expect(observedRequest?.query).toMatchObject({ seriesLimit: 50, filters: { groupByAttributeKeys: ["http.route"] }, }) - const excessiveLimit = await harness.request("POST", "/v2/query", key.secret, { + const excessiveLimit = await harness.request("POST", "/v2/traces/timeseries", key.secret, { start_time: START, end_time: END, - query: { - kind: "timeseries", - source: "traces", - metric: "count", - group_by: ["service"], - series_limit: 101, - }, + aggregation: "count", + group_by: "service", + series_limit: 101, }) expect(excessiveLimit.status).toBe(400) await harness.dispose() }) + it("coerces BYO-ClickHouse numeric strings before encoding aggregation responses", async () => { + const queryEngine: QueryEngineServiceShape = { + ...queryEngineStub, + execute: (_tenant, request) => + Effect.succeed( + (request.query.kind === "breakdown" + ? { + result: { + kind: "breakdown", + source: "metrics", + data: [{ name: "api", value: "42" }], + }, + } + : { + result: { + kind: "timeseries", + source: "metrics", + data: [{ bucket: "2026-07-15 12:00:00", series: { all: "42" } }], + }, + }) as unknown as QueryEngineExecuteResponse, + ), + } + const harness = makeHarness(warehouseStub, queryEngine) + const key = await harness.bootstrapKey(["metrics:read"]) + const filters = { metric_name: "http.server.duration", metric_type: "histogram" } + const timeseries = await harness.request("POST", "/v2/metrics/timeseries", key.secret, { + ...allowedWindow(), + aggregation: "avg", + filters, + }) + expect(timeseries.body.series[0].points[0].value).toBe(42) + const breakdown = await harness.request("POST", "/v2/metrics/breakdown", key.secret, { + ...allowedWindow(), + aggregation: "avg", + group_by: "service", + filters, + }) + expect(breakdown.body.data[0].value).toBe(42) + await harness.dispose() + }) + it("applies bounded ClickHouse settings to log body searches", async () => { let observedOptions: Parameters[2] const observingWarehouse: WarehouseQueryServiceShape = { @@ -468,7 +625,7 @@ describe("v2 telemetry reads over HTTP", () => { const response = await harness.request("POST", "/v2/logs/search", key.secret, { start_time: START, end_time: END, - search: "checkout failed", + filters: { body_search: "checkout failed" }, }) expect(response.status).toBe(200) expect(observedOptions?.settings).toMatchObject({ maxBlockSize: 512 }) diff --git a/apps/api/src/routes/v2/telemetry.http.ts b/apps/api/src/routes/v2/telemetry.http.ts index 5f7178535..076fdc940 100644 --- a/apps/api/src/routes/v2/telemetry.http.ts +++ b/apps/api/src/routes/v2/telemetry.http.ts @@ -13,12 +13,11 @@ import { type V2Service, type V2ServiceMapEdge, type V2Span, - type V2StructuredQueryResult, type V2TraceSummary, } from "@maple/domain/http/v2" import { CH, QueryEngineExecuteRequest } from "@maple/query-engine" import { LOGS_BODY_SEARCH_SETTINGS } from "@maple/query-engine/profiles" -import { MAX_QUERY_RANGE_SECONDS } from "@maple/query-engine/runtime" +import { computeBucketSeconds, MAX_QUERY_RANGE_SECONDS } from "@maple/query-engine/runtime" import { Effect, Encoding, Option, Result, Schema } from "effect" import { WarehouseQueryService } from "../../lib/WarehouseQueryService" import { QueryEngineService } from "../../services/QueryEngineService" @@ -55,6 +54,11 @@ const serviceCatalogRowSchema = Schema.Struct({ const PARTITION_HINT_RADIUS_MS = 60 * 60 * 1000 const PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT = 50 +const PUBLIC_BREAKDOWN_DEFAULT_LIMIT = 20 +const MAX_SEARCH_RANGE_SECONDS = 60 * 60 * 24 * 7 +const MAX_BREAKDOWN_RANGE_SECONDS = 60 * 60 * 24 * 30 +const MAX_UNFILTERED_BREAKDOWN_RANGE_SECONDS = 60 * 60 * 24 +const MAX_TIMESERIES_BUCKETS = 1_500 const mapWarehouseError = (operation: string) => () => dependencyUnavailable(`${operation}_unavailable`) @@ -65,7 +69,11 @@ const toWarehouseDateTime = (value: string, param: string) => { : Effect.succeed(new Date(ms).toISOString().replace("T", " ").replace(/Z$/, "")) } -const parseWindow = (start: string, end: string) => +const parseWindow = ( + start: string, + end: string, + options: { readonly maxSeconds?: number; readonly rangeLabel?: string } = {}, +) => Effect.gen(function* () { const startMs = Date.parse(start) const endMs = Date.parse(end) @@ -75,11 +83,12 @@ const parseWindow = (start: string, end: string) => ) } const rangeSeconds = (endMs - startMs) / 1000 - if (rangeSeconds > MAX_QUERY_RANGE_SECONDS) { + const maxSeconds = options.maxSeconds ?? MAX_QUERY_RANGE_SECONDS + if (rangeSeconds > maxSeconds) { return yield* Effect.fail( invalidRequest( "time_range_too_large", - "Telemetry queries support a maximum time range of 31 days.", + `${options.rangeLabel ?? "Telemetry queries"} support a maximum time range of ${Math.floor(maxSeconds / 86_400)} days.`, "start_time", ), ) @@ -246,8 +255,8 @@ const toTraceSummary = (row: { root_span_name: row.rootSpanName, root_span_kind: row.rootSpanKind, root_service_name: row.rootServiceName, - status_code: row.statusCode, - has_error: Number(row.hasError) !== 0, + root_status_code: row.statusCode, + root_has_error: Number(row.hasError) !== 0, deployment_environment: row.deploymentEnvironment || null, service_namespace: row.serviceNamespace || null, http_method: row.httpMethod || null, @@ -288,93 +297,146 @@ const attributeFilters = ( filters: | ReadonlyArray<{ key: string - value?: string - mode: string + value?: string | number + operator: "equals" | "exists" | "gt" | "gte" | "lt" | "lte" | "contains" negated?: boolean }> | undefined, ) => filters?.map((filter) => ({ key: filter.key, - ...(filter.value !== undefined ? { value: filter.value } : {}), - mode: filter.mode, + ...(filter.value !== undefined ? { value: String(filter.value) } : {}), + mode: filter.operator, ...(filter.negated !== undefined ? { negated: filter.negated } : {}), })) -const toInternalQuery = (query: Record): Record => { - const mapped: Record = { - kind: query.kind, - source: query.source, - metric: query.metric, - groupBy: query.group_by, - bucketSeconds: query.bucket_seconds, - seriesLimit: - query.kind === "timeseries" - ? (query.series_limit ?? PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT) - : undefined, - limit: query.limit, - apdexThresholdMs: query.apdex_threshold_ms, - } - const filters = query.filters - if (filters) { - mapped.filters = { - serviceName: filters.service_name, - spanName: filters.span_name, - rootSpansOnly: filters.root_spans_only, - errorsOnly: filters.errors_only, - environments: filters.environments, - namespaces: filters.namespaces, - minDurationMs: filters.min_duration_ms, - maxDurationMs: filters.max_duration_ms, - severity: filters.severity, - traceId: filters.trace_id, - search: filters.search, - metricName: filters.metric_name, - metricType: filters.metric_type, - groupByAttributeKey: filters.group_by_attribute_key, - groupByAttributeKeys: filters.group_by_attribute_keys, - groupByResourceAttributeKey: filters.group_by_resource_attribute_key, - attributeFilters: attributeFilters(filters.attribute_filters), - resourceAttributeFilters: attributeFilters(filters.resource_attribute_filters), - } +const traceFilters = (filters: Record | undefined, groupByAttributeKey?: string) => { + if (!filters && !groupByAttributeKey) return undefined + const httpFilters = [ + ...(filters?.http_method + ? [{ key: "http.method", value: filters.http_method, mode: "equals" as const }] + : []), + ...(filters?.http_route + ? [{ key: "http.route", value: filters.http_route, mode: "equals" as const }] + : []), + ...(filters?.http_status_code + ? [{ key: "http.status_code", value: filters.http_status_code, mode: "equals" as const }] + : []), + ] + return { + serviceName: filters?.service_name, + spanName: filters?.span_name, + statusCode: filters?.status_code, + rootSpansOnly: filters?.span_scope === "root" ? true : undefined, + errorsOnly: filters?.has_error, + environments: filters?.deployment_environment ? [filters.deployment_environment] : undefined, + namespaces: filters?.service_namespace ? [filters.service_namespace] : undefined, + minDurationMs: filters?.min_duration_ms, + maxDurationMs: filters?.max_duration_ms, + groupByAttributeKeys: groupByAttributeKey ? [groupByAttributeKey] : undefined, + attributeFilters: [...(attributeFilters(filters?.attributes) ?? []), ...httpFilters], + resourceAttributeFilters: attributeFilters(filters?.resource_attributes), } - return Object.fromEntries(Object.entries(mapped).filter(([, value]) => value !== undefined)) } -const structuredResult = (result: { - kind: "timeseries" | "breakdown" - source: "traces" | "logs" | "metrics" - data: ReadonlyArray -}): V2StructuredQueryResult => ({ - object: "query_result", - kind: result.kind, - source: result.source, - timeseries: - result.kind === "timeseries" - ? (result.data as ReadonlyArray<{ bucket: string; series: Record }>).map( - (point) => ({ bucket: chToIso(point.bucket), series: point.series }), - ) - : [], - breakdown: result.kind === "breakdown" ? (result.data as V2StructuredQueryResult["breakdown"]) : [], +const logFilters = (filters: Record | undefined) => + filters + ? { + serviceName: filters.service_name, + severity: filters.severity, + minSeverity: filters.minimum_severity, + traceId: filters.trace_id, + spanId: filters.span_id, + search: filters.body_search, + environments: filters.deployment_environment ? [filters.deployment_environment] : undefined, + namespaces: filters.service_namespace ? [filters.service_namespace] : undefined, + attributeFilters: attributeFilters(filters.attributes), + resourceAttributeFilters: attributeFilters(filters.resource_attributes), + } + : undefined + +const metricFilters = ( + filters: Record, + groupByAttributeKey?: string, + groupByResourceAttributeKey?: string, +) => ({ + metricName: filters.metric_name, + metricType: filters.metric_type, + serviceName: filters.service_name, + groupByAttributeKey, + groupByResourceAttributeKey, }) -const queryError = (error: unknown) => { +const queryError = (signal: "trace" | "log" | "metric") => (error: unknown) => { const tag = typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "" return tag.includes("Validation") - ? invalidRequest("query_invalid", "The query specification is invalid.", "query") - : dependencyUnavailable("query_unavailable") + ? invalidRequest(`${signal}_query_invalid`, "The aggregation request is invalid.", "aggregation") + : dependencyUnavailable(`${signal}_query_unavailable`) } -const decodeQueryEngineRequest = (input: unknown) => +const decodeQueryEngineRequest = (input: unknown, signal: "trace" | "log" | "metric") => Schema.decodeUnknownEffect(QueryEngineExecuteRequest)(input).pipe( Effect.mapError(() => - invalidRequest("query_invalid", "The query specification is invalid.", "query"), + invalidRequest(`${signal}_query_invalid`, "The aggregation request is invalid.", "aggregation"), + ), + ) + +const validateTimeseriesBucket = ( + startTime: string, + endTime: string, + rangeSeconds: number, + requestedBucketSeconds: number | undefined, +) => { + const bucketSeconds = + requestedBucketSeconds ?? computeBucketSeconds(Date.parse(startTime), Date.parse(endTime)) + return Math.floor(rangeSeconds / bucketSeconds) + 1 > MAX_TIMESERIES_BUCKETS + ? Effect.fail( + invalidRequest( + "bucket_count_too_large", + "bucket_seconds produces more than 1,500 buckets.", + "bucket_seconds", + ), + ) + : Effect.succeed(bucketSeconds) +} + +const validateBreakdownRange = (rangeSeconds: number, filters: unknown) => { + if (rangeSeconds <= MAX_UNFILTERED_BREAKDOWN_RANGE_SECONDS) return Effect.void + if ( + filters && + typeof filters === "object" && + Object.values(filters).some((value) => + Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null, + ) + ) { + return Effect.void + } + return Effect.fail( + invalidRequest( + "breakdown_filter_required", + "Breakdowns over 24 hours require at least one narrowing filter.", + "filters", ), ) +} + +const pivotTimeseries = ( + data: ReadonlyArray<{ readonly bucket: string; readonly series: Readonly> }>, + grouped: boolean, +) => { + const names = [...new Set(data.flatMap((point) => Object.keys(point.series)))].sort() + return names.map((name) => ({ + group: grouped ? name : null, + points: data + .filter((point) => name in point.series) + .map((point) => ({ timestamp: chToIso(point.bucket), value: Number(point.series[name]) })), + })) +} export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (handlers) => Effect.gen(function* () { const warehouse = yield* WarehouseQueryService + const queryEngine = yield* QueryEngineService const hierarchy = Effect.fn("HttpV2Traces.hierarchy")(function* ( tenant: CurrentTenant.TenantSchema, @@ -398,20 +460,30 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand .handle("search", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const window = yield* parseWindow(payload.start_time, payload.end_time) + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_SEARCH_RANGE_SECONDS, + rangeLabel: "Trace search", + }) const limit = payload.limit ?? 20 const cursorParts = yield* decodeKeysetCursor(payload.cursor, "trc", 2) + const filters = payload.filters + const internalFilters = traceFilters(filters) const compiled = CH.compile( CH.traceSummariesQuery({ - serviceName: payload.service_name, - spanName: payload.span_name, - hasError: payload.has_error, - minDurationMs: payload.min_duration_ms, - maxDurationMs: payload.max_duration_ms, - httpMethod: payload.http_method, - httpStatusCode: payload.http_status_code, - deploymentEnv: payload.deployment_environment, - namespace: payload.service_namespace, + serviceName: filters?.service_name, + spanName: filters?.span_name, + statusCode: filters?.status_code, + hasError: filters?.has_error, + minDurationMs: filters?.min_duration_ms, + maxDurationMs: filters?.max_duration_ms, + httpMethod: filters?.http_method, + httpRoute: filters?.http_route, + httpStatusCode: filters?.http_status_code, + deploymentEnv: filters?.deployment_environment, + namespace: filters?.service_namespace, + spanScope: filters?.span_scope, + attributeFilters: internalFilters?.attributeFilters, + resourceAttributeFilters: internalFilters?.resourceAttributeFilters, limit: limit + 1, cursor: cursorParts ? { timestamp: cursorParts[0]!, traceId: cursorParts[1]! } @@ -436,6 +508,102 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand } }), ) + .handle("timeseries", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_QUERY_RANGE_SECONDS, + rangeLabel: "Trace timeseries", + }) + const bucketSeconds = yield* validateTimeseriesBucket( + payload.start_time, + payload.end_time, + window.rangeSeconds, + payload.bucket_seconds, + ) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "timeseries", + source: "traces", + metric: payload.aggregation, + groupBy: payload.group_by ? [payload.group_by] : undefined, + bucketSeconds, + seriesLimit: payload.series_limit ?? PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT, + apdexThresholdMs: + payload.aggregation === "apdex" + ? (payload.apdex_threshold_ms ?? 500) + : undefined, + filters: traceFilters(payload.filters, payload.group_by_attribute_key), + }, + }, + "trace", + ) + const response = yield* queryEngine + .execute(tenant, request) + .pipe(Effect.mapError(queryError("trace"))) + if (response.result.kind !== "timeseries") { + return yield* Effect.fail(dependencyUnavailable("trace_query_unavailable")) + } + return { + object: "trace_timeseries" as const, + aggregation: payload.aggregation, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + bucket_seconds: bucketSeconds, + group_by: payload.group_by ?? null, + series: pivotTimeseries(response.result.data, payload.group_by !== undefined), + } + }), + ) + .handle("breakdown", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_BREAKDOWN_RANGE_SECONDS, + rangeLabel: "Trace breakdown", + }) + yield* validateBreakdownRange(window.rangeSeconds, payload.filters) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "breakdown", + source: "traces", + metric: payload.aggregation, + groupBy: payload.group_by, + limit: payload.limit ?? PUBLIC_BREAKDOWN_DEFAULT_LIMIT, + apdexThresholdMs: + payload.aggregation === "apdex" + ? (payload.apdex_threshold_ms ?? 500) + : undefined, + filters: traceFilters(payload.filters, payload.group_by_attribute_key), + }, + }, + "trace", + ) + const response = yield* queryEngine + .execute(tenant, request) + .pipe(Effect.mapError(queryError("trace"))) + if (response.result.kind !== "breakdown") { + return yield* Effect.fail(dependencyUnavailable("trace_query_unavailable")) + } + return { + object: "trace_breakdown" as const, + aggregation: payload.aggregation, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + group_by: payload.group_by, + data: response.result.data.map((item) => ({ + name: item.name, + value: Number(item.value), + })), + } + }), + ) .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context @@ -486,25 +654,22 @@ export const HttpV2TracesLive = HttpApiBuilder.group(MapleApiV2, "traces", (hand export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers) => Effect.gen(function* () { const warehouse = yield* WarehouseQueryService + const queryEngine = yield* QueryEngineService return handlers .handle("search", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const window = yield* parseWindow(payload.start_time, payload.end_time) + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_SEARCH_RANGE_SECONDS, + rangeLabel: "Log search", + }) const limit = payload.limit ?? 20 const cursorParts = yield* decodeKeysetCursor(payload.cursor, "log", 5) + const filters = payload.filters + const internalFilters = logFilters(filters) const compiled = CH.compile( CH.logsListQuery({ - serviceName: payload.service_name, - severity: payload.severity, - minSeverity: payload.min_severity, - traceId: payload.trace_id, - spanId: payload.span_id, - search: payload.search, - environments: payload.deployment_environment - ? [payload.deployment_environment] - : undefined, - namespaces: payload.service_namespace ? [payload.service_namespace] : undefined, + ...internalFilters, limit: limit + 1, cursorIdentity: cursorParts ? { @@ -522,7 +687,7 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers .compiledQuery(tenant, compiled, { profile: "list", context: "v2LogSearch", - settings: payload.search ? LOGS_BODY_SEARCH_SETTINGS : undefined, + settings: filters?.body_search ? LOGS_BODY_SEARCH_SETTINGS : undefined, }) .pipe(Effect.mapError(mapWarehouseError("log_search"))) const dataRows = rows.slice(0, limit) @@ -545,6 +710,94 @@ export const HttpV2LogsLive = HttpApiBuilder.group(MapleApiV2, "logs", (handlers } }), ) + .handle("timeseries", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_QUERY_RANGE_SECONDS, + rangeLabel: "Log timeseries", + }) + const bucketSeconds = yield* validateTimeseriesBucket( + payload.start_time, + payload.end_time, + window.rangeSeconds, + payload.bucket_seconds, + ) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "timeseries", + source: "logs", + metric: "count", + groupBy: payload.group_by ? [payload.group_by] : undefined, + bucketSeconds, + seriesLimit: payload.series_limit ?? PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT, + filters: logFilters(payload.filters), + }, + }, + "log", + ) + const response = yield* queryEngine + .execute(tenant, request) + .pipe(Effect.mapError(queryError("log"))) + if (response.result.kind !== "timeseries") { + return yield* Effect.fail(dependencyUnavailable("log_query_unavailable")) + } + return { + object: "log_timeseries" as const, + aggregation: "count" as const, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + bucket_seconds: bucketSeconds, + group_by: payload.group_by ?? null, + series: pivotTimeseries(response.result.data, payload.group_by !== undefined), + } + }), + ) + .handle("breakdown", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_BREAKDOWN_RANGE_SECONDS, + rangeLabel: "Log breakdown", + }) + yield* validateBreakdownRange(window.rangeSeconds, payload.filters) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "breakdown", + source: "logs", + metric: "count", + groupBy: payload.group_by, + limit: payload.limit ?? PUBLIC_BREAKDOWN_DEFAULT_LIMIT, + filters: logFilters(payload.filters), + }, + }, + "log", + ) + const response = yield* queryEngine + .execute(tenant, request) + .pipe(Effect.mapError(queryError("log"))) + if (response.result.kind !== "breakdown") { + return yield* Effect.fail(dependencyUnavailable("log_query_unavailable")) + } + return { + object: "log_breakdown" as const, + aggregation: "count" as const, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + group_by: payload.group_by, + data: response.result.data.map((item) => ({ + name: item.name, + value: Number(item.value), + })), + } + }), + ) .handle("retrieve", ({ params }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context @@ -623,36 +876,97 @@ export const HttpV2MetricsLive = HttpApiBuilder.group(MapleApiV2, "metrics", (ha .handle("timeseries", ({ payload }) => Effect.gen(function* () { const tenant = yield* CurrentTenant.Context - const window = yield* parseWindow(payload.start_time, payload.end_time) - const request = yield* decodeQueryEngineRequest({ - startTime: window.startTime, - endTime: window.endTime, - query: { - kind: "timeseries", - source: "metrics", - metric: payload.metric, - groupBy: payload.group_by, - bucketSeconds: payload.bucket_seconds, - seriesLimit: payload.series_limit ?? PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT, - filters: { - metricName: payload.metric_name, - metricType: payload.metric_type, - serviceName: payload.service_name, - groupByAttributeKey: payload.group_by_attribute_key, - groupByResourceAttributeKey: payload.group_by_resource_attribute_key, - attributeFilters: attributeFilters(payload.attribute_filters), - resourceAttributeFilters: attributeFilters( - payload.resource_attribute_filters, + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_QUERY_RANGE_SECONDS, + rangeLabel: "Metric timeseries", + }) + const bucketSeconds = yield* validateTimeseriesBucket( + payload.start_time, + payload.end_time, + window.rangeSeconds, + payload.bucket_seconds, + ) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "timeseries", + source: "metrics", + metric: payload.aggregation, + groupBy: payload.group_by ? [payload.group_by] : undefined, + bucketSeconds, + seriesLimit: payload.series_limit ?? PUBLIC_TIMESERIES_DEFAULT_SERIES_LIMIT, + filters: metricFilters( + payload.filters, + payload.group_by_attribute_key, + payload.group_by_resource_attribute_key, ), }, }, + "metric", + ) + const response = yield* queryEngine + .execute(tenant, request) + .pipe(Effect.mapError(queryError("metric"))) + if (response.result.kind !== "timeseries") { + return yield* Effect.fail(dependencyUnavailable("metric_query_unavailable")) + } + return { + object: "metric_timeseries" as const, + aggregation: payload.aggregation, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + bucket_seconds: bucketSeconds, + group_by: payload.group_by ?? null, + series: pivotTimeseries(response.result.data, payload.group_by !== undefined), + } + }), + ) + .handle("breakdown", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + const window = yield* parseWindow(payload.start_time, payload.end_time, { + maxSeconds: MAX_BREAKDOWN_RANGE_SECONDS, + rangeLabel: "Metric breakdown", }) + yield* validateBreakdownRange(window.rangeSeconds, payload.filters) + const request = yield* decodeQueryEngineRequest( + { + startTime: window.startTime, + endTime: window.endTime, + query: { + kind: "breakdown", + source: "metrics", + metric: payload.aggregation, + groupBy: payload.group_by, + limit: payload.limit ?? PUBLIC_BREAKDOWN_DEFAULT_LIMIT, + filters: metricFilters( + payload.filters, + payload.group_by_attribute_key, + payload.group_by_resource_attribute_key, + ), + }, + }, + "metric", + ) const response = yield* queryEngine .execute(tenant, request) - .pipe(Effect.mapError(queryError)) - if (response.result.kind !== "timeseries") + .pipe(Effect.mapError(queryError("metric"))) + if (response.result.kind !== "breakdown") { return yield* Effect.fail(dependencyUnavailable("metric_query_unavailable")) - return structuredResult(response.result) + } + return { + object: "metric_breakdown" as const, + aggregation: payload.aggregation, + start_time: timestamp(payload.start_time), + end_time: timestamp(payload.end_time), + group_by: payload.group_by, + data: response.result.data.map((item) => ({ + name: item.name, + value: Number(item.value), + })), + } }), ) }), @@ -810,25 +1124,3 @@ export const HttpV2ServiceMapLive = HttpApiBuilder.group(MapleApiV2, "serviceMap ) }), ) - -export const HttpV2QueryLive = HttpApiBuilder.group(MapleApiV2, "query", (handlers) => - Effect.gen(function* () { - const queryEngine = yield* QueryEngineService - return handlers.handle("execute", ({ payload }) => - Effect.gen(function* () { - const tenant = yield* CurrentTenant.Context - const window = yield* parseWindow(payload.start_time, payload.end_time) - const request = yield* decodeQueryEngineRequest({ - startTime: window.startTime, - endTime: window.endTime, - query: toInternalQuery(payload.query), - }) - const response = yield* queryEngine.execute(tenant, request).pipe(Effect.mapError(queryError)) - if (response.result.kind !== "timeseries" && response.result.kind !== "breakdown") { - return yield* Effect.fail(dependencyUnavailable("query_unavailable")) - } - return structuredResult(response.result) - }), - ) - }), -) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index 49a75a09b..2f56ba842 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -28,7 +28,6 @@ import { HttpV2SessionReplaysLive } from "./session-replays.http" import { HttpV2LogsLive, HttpV2MetricsLive, - HttpV2QueryLive, HttpV2ServiceMapLive, HttpV2ServicesLive, HttpV2TracesLive, @@ -61,7 +60,6 @@ export const AllV2GroupLayersLive = Layer.mergeAll( HttpV2MetricsLive, HttpV2ServicesLive, HttpV2ServiceMapLive, - HttpV2QueryLive, ) export const ApiV2RateLimiterAllowAllLayer = Layer.succeed(ApiV2RateLimiter, { diff --git a/bun.lock b/bun.lock index fb8224c66..4aaf6b5b9 100644 --- a/bun.lock +++ b/bun.lock @@ -339,6 +339,15 @@ "vitest": "catalog:", }, }, + "examples/alchemy-maple": { + "name": "@maple-examples/alchemy", + "devDependencies": { + "@maple-dev/alchemy": "workspace:*", + "alchemy": "2.0.0-beta.61", + "effect": "catalog:effect", + "typescript": "catalog:tooling", + }, + }, "examples/effect-todo": { "name": "@maple-examples/effect-todo", "version": "0.0.0", @@ -362,6 +371,24 @@ "vite": "catalog:", }, }, + "lib/alchemy-maple": { + "name": "@maple-dev/alchemy", + "version": "0.1.0", + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@maple/domain": "workspace:*", + "@types/node": "catalog:tooling", + "alchemy": "2.0.0-beta.61", + "effect": "catalog:effect", + "tsdown": "^0.21.7", + "typescript": "catalog:tooling", + "vitest": "catalog:", + }, + "peerDependencies": { + "alchemy": ">=2.0.0-beta.61 <3", + "effect": ">=4.0.0-beta.93 || >=4.0.0", + }, + }, "lib/clickhouse-builder": { "name": "@maple-dev/clickhouse-builder", "version": "0.1.0", @@ -1483,12 +1510,16 @@ "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], + "@maple-dev/alchemy": ["@maple-dev/alchemy@workspace:lib/alchemy-maple"], + "@maple-dev/browser": ["@maple-dev/browser@workspace:packages/browser"], "@maple-dev/clickhouse-builder": ["@maple-dev/clickhouse-builder@workspace:lib/clickhouse-builder"], "@maple-dev/effect-sdk": ["@maple-dev/effect-sdk@workspace:lib/effect-sdk"], + "@maple-examples/alchemy": ["@maple-examples/alchemy@workspace:examples/alchemy-maple"], + "@maple-examples/effect-todo": ["@maple-examples/effect-todo@workspace:examples/effect-todo"], "@maple/alerting": ["@maple/alerting@workspace:apps/alerting"], diff --git a/docs/api-v2.md b/docs/api-v2.md index de4771ab8..47ce54e63 100644 --- a/docs/api-v2.md +++ b/docs/api-v2.md @@ -128,42 +128,41 @@ Stripe-style `expand[]` is deliberately omitted: responses embed the small, alwa Implemented in phases; the pilot (`api_keys`) ships first and proves every convention. -| Resource | Endpoints | Backing v1 group / service | -| ------------------------------------ | -------------------------------------------------------------------------------------------------- | ---------------------------------------- | -| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` | -| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` | -| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/instantiate) + Perses import | `dashboards` | -| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `alerts` | -| `alerts/destinations` ✅ | CRUD + `test` | `alerts` | -| `alerts/incidents` ✅ | list/retrieve | `alerts` | -| `error_issues` 🟡 | list/retrieve ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | -| `investigations` ✅ | list/retrieve/create/status | `investigations` | -| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue + `PATCH` settings | `anomalies` | -| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `recommendationIssues` | -| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `scrapeTargets` | -| `attribute_mappings` ✅ | CRUD | `ingestAttributeMappings` | -| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | -| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | -| `traces` ✅ | `POST /v2/traces/search`, `GET /v2/traces/{trace_id}`, `GET /v2/traces/{trace_id}/spans/{span_id}` | `queryEngine`, `observability` | -| `logs` ✅ | `POST /v2/logs/search`, `GET /v2/logs/{id}` | `queryEngine` | -| `metrics` ✅ | `GET /v2/metrics`, `POST /v2/metrics/timeseries` | `queryEngine` | -| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}` | `queryEngine` | -| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | -| `query` ✅ | `POST /v2/query` — structured telemetry queries | `queryEngine` | +| Resource | Endpoints | Backing v1 group / service | +| ------------------------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------- | +| `api_keys` ✅ pilot | list/create/retrieve/roll/revoke, `scopes` param | `apiKeys` / `ApiKeysService` | +| `ingest_keys` ✅ | retrieve, `POST …/public/roll`, `POST …/private/roll` | `ingestKeys` | +| `dashboards` ✅ | CRUD + `versions` (list/retrieve/restore) + `templates` (list/instantiate) + Perses import | `dashboards` | +| `alerts/rules` ✅ | CRUD + `test` + `preview` + `checks` | `alerts` | +| `alerts/destinations` ✅ | CRUD + `test` | `alerts` | +| `alerts/incidents` ✅ | list/retrieve | `alerts` | +| `error_issues` 🟡 | list/retrieve ✅; `events`, `comments`, `transitions`, `assignee`, `severity` deferred | `errors` | +| `investigations` ✅ | list/retrieve/create/status | `investigations` | +| `anomalies` ✅ | incidents list/retrieve/timeseries/resolve/link-issue + `PATCH` settings | `anomalies` | +| `instrumentation/recommendations` ✅ | list + dismiss/reopen | `recommendationIssues` | +| `scrape_targets` ✅ | CRUD + `probe` + `checks` | `scrapeTargets` | +| `attribute_mappings` ✅ | CRUD | `ingestAttributeMappings` | +| `session_replays` ✅ | `search`/retrieve + events/transcript/`for_trace` (reduced; `facets`/`trace-summaries` deferred) | `sessionReplays` | +| `organization` 🟡 | retrieve (GET only shipped); update settings (incl. ClickHouse BYOC) + delete deferred | `organizations`, `orgClickHouseSettings` | +| `traces` ✅ | search/timeseries/breakdown + direct trace/span reads | `queryEngine`, `observability` | +| `logs` ✅ | search/timeseries/breakdown + direct log reads | `queryEngine` | +| `metrics` ✅ | catalog + timeseries/breakdown | `queryEngine` | +| `services` ✅ | `GET /v2/services`, `GET /v2/services/{name}` | `queryEngine` | +| `service_map` ✅ | `GET /v2/service_map` | `queryEngine` | The long tail of ~40 query-engine RPC endpoints (facets, infra hosts/pods/nodes/workloads, Cloudflare/PlanetScale infra) starts in the internal RPC tier and is promoted into `/v2` individually as shapes stabilize. ### Telemetry reads -Phase 2 exposes traces, logs, metrics, services, the service map, and the common query engine. Collection and aggregation operations require explicit ISO-8601 UTC `start_time` and `end_time`; `end_time` must be later than `start_time`, and ranges are capped at 31 days. Direct trace/span retrieval uses the `(OrgId, TraceId, SpanId)` sorting-key prefix so it returns the complete retained trace without a correctness-limiting timestamp window. Direct log retrieval derives its narrow partition window from the timestamp embedded in the log ID. +Phase 2 exposes traces, logs, metrics, services, and the service map. Telemetry search and aggregation requests require explicit ISO-8601 UTC `start_time` and `end_time`. Search windows are capped at 7 days, timeseries at 31 days and 1,500 buckets, and breakdowns at 30 days. A breakdown over 24 hours requires an additional narrowing filter. Direct trace/span retrieval uses the `(OrgId, TraceId, SpanId)` sorting-key prefix so it returns the complete retained trace without a correctness-limiting timestamp window. Direct log retrieval derives its narrow partition window from the timestamp embedded in the log ID. Trace IDs, span IDs, metric names, and service names remain their native OpenTelemetry identifiers. Logs have no native record ID, so search results return a deterministic `log_…` ID containing a compact timestamp and a complete-record fingerprint. Malformed log IDs return `log_id_invalid`; records outside retention return `log_not_found`. -All telemetry lists use the standard cursor envelope with a default limit of 20 and maximum of 100. Trace results are root-span summaries ordered by start time and trace ID. Log ordering includes timestamp plus the complete composite identity. Services are grouped by service name across namespaces and deployment environments. The service map includes only service-to-service edges in this version; database, external, and infrastructure nodes remain internal APIs. +All telemetry lists use the standard cursor envelope with a default limit of 20 and maximum of 100. Trace search filters match any span by default and return the owning trace's root summary; `filters.span_scope: "root"` restricts matching to root spans. Trace results are ordered by start time and trace ID. Log ordering includes timestamp plus the complete composite identity. Attribute filters support equality, existence, substring, and numeric comparisons with optional negation; each attribute-filter collection is capped at 20 entries. -`POST /v2/query` accepts `timeseries` and `breakdown` specifications for trace, log, and metric sources using snake_case fields. Responses use the common `query_result` envelope. Raw SQL is intentionally unavailable through the public API because tenant isolation must not depend on inspecting caller-authored SQL text. Warehouse diagnostics are never returned to callers. +Each signal has explicit `POST …/timeseries` and `POST …/breakdown` operations. Requests use `aggregation`, optional scalar `group_by` for timeseries, required `group_by` for breakdowns, and nested `filters`. Timeseries responses contain chronological `series[].points`; breakdown responses contain ordered `data` entries. Inactive fields and query-engine terminology are never returned. Metric `rate` and `increase` require `metric_type: "sum"`; Apdex defaults to a 500 ms threshold. -Telemetry scope families are `traces`, `logs`, `metrics`, `services`, `service_map`, and `query`. Search, timeseries, and query POSTs are read-only operations for scope enforcement, so `:read` is sufficient. +Telemetry scope families are `traces`, `logs`, `metrics`, `services`, and `service_map`. Search, timeseries, and breakdown POSTs are read-only operations for scope enforcement, so the corresponding `:read` scope is sufficient. There is no generic `/v2/query`, raw SQL, facet, attribute-discovery, or raw OTel datapoint endpoint. Not in v2: org membership and invitations (delegated to Clerk; revisit if/when a members API is needed). @@ -175,7 +174,7 @@ The dashboard can reconcile optimistic writes against ElectricSQL synced shapes - **Phase 0 (this change)** — conventions doc, v2 primitives (`public-id`, `envelopes`, `errors`, `auth`), scoped API keys (schema + service + enforcement), `MapleApiV2` shell mounted at `/v2` with Scalar docs at `/v2/docs`, pilot resource `api_keys` end-to-end with tests. - **Phase 1 — core resources**: dashboards, alerts, error issues, scrape targets, ingest keys, attribute mappings, investigations, anomalies, recommendations, organization, session replays. Thin handler adapters over existing services; `txid` preserved. -- **Phase 2 ✅ — telemetry reads**: traces/logs/metrics/services/service_map/query over `QueryEngineService`. +- **Phase 2 ✅ — telemetry reads**: signal-scoped traces/logs/metrics query operations plus services/service_map over `QueryEngineService`. - **Phase 3 — internal RPC tier + dashboard migration**: `RpcGroup` contracts in `packages/domain/src/rpc/` served at `/rpc`; dashboard gets a `MapleApiV2AtomClient` (same wiring as `apps/web/src/lib/services/common/atom-client.ts`, pointed at `MapleApiV2`) plus an `RpcClient`; migrate group-by-group, deleting v1 groups as they empty. (Note: the billing-scoped 401 retry in `atom-client.ts` must follow billing to its RPC home.) - **Phase 4 — hardening**: `Idempotency-Key`, `Maple-Version` header enforcement. Per-key rate limiting is implemented. - **Phase 5 — events & webhooks**: `evt_` event objects, `GET /v2/events`, `/v2/webhook_endpoints` CRUD, HMAC-signed deliveries (`Maple-Signature`) via an outbox drained by the alerting worker. diff --git a/examples/alchemy-maple/README.md b/examples/alchemy-maple/README.md new file mode 100644 index 000000000..a5c7aa2a1 --- /dev/null +++ b/examples/alchemy-maple/README.md @@ -0,0 +1,14 @@ +# alchemy-maple example + +Declares Maple resources (a Slack alert destination, two alert rules, a dashboard, a scoped API key, and the org ingest keys) as infrastructure-as-code via [`@maple-dev/alchemy`](../../lib/alchemy-maple). + +```bash +# one-time: build the provider package +bun run --cwd ../../lib/alchemy-maple build + +# deploy (needs an org-admin maple_ak_… key; MAPLE_API_URL to target non-prod) +MAPLE_API_KEY=maple_ak_… SLACK_WEBHOOK_URL=https://hooks.slack.com/… bun alchemy deploy + +# tear down +MAPLE_API_KEY=maple_ak_… bun alchemy destroy +``` diff --git a/examples/alchemy-maple/alchemy.run.ts b/examples/alchemy-maple/alchemy.run.ts new file mode 100644 index 000000000..7c459a90a --- /dev/null +++ b/examples/alchemy-maple/alchemy.run.ts @@ -0,0 +1,69 @@ +/** + * Example stack: Maple observability resources as infrastructure-as-code. + * + * Run with a Maple API key (org-admin, or scope it to what you declare): + * + * MAPLE_API_KEY=maple_ak_… bun alchemy deploy + * + * Requires `@maple-dev/alchemy` to be built first (`bun run --cwd ../../lib/alchemy-maple build`). + */ +import * as Alchemy from "alchemy" +import * as Maple from "@maple-dev/alchemy" +import { Effect } from "effect" + +export default Alchemy.Stack( + "maple-example", + { providers: Maple.providers() }, + Effect.gen(function* () { + // A Slack notification channel. The webhook URL is write-only server-side. + const slack = yield* Maple.AlertDestination("oncall-slack", { + type: "slack", + name: "On-call Slack", + webhook_url: process.env.SLACK_WEBHOOK_URL ?? "https://hooks.slack.com/services/CHANGE/ME/PLEASE", + channel_label: "#incidents", + }) + + // An error-rate alert delivering to it — Alchemy orders the dependency. + yield* Maple.AlertRule("checkout-error-rate", { + name: "Checkout error rate", + severity: "critical", + signal_type: "error_rate", + comparator: "gt", + threshold: 0.05, + window_minutes: 5, + service_names: ["checkout"], + destination_ids: [slack.destinationId], + }) + + // A latency alert on the same channel. + yield* Maple.AlertRule("checkout-p95", { + name: "Checkout p95 latency", + severity: "warning", + signal_type: "p95_latency", + comparator: "gt", + threshold: 750, + window_minutes: 10, + service_names: ["checkout"], + destination_ids: [slack.destinationId], + }) + + // A dashboard shell (widgets are the v2 wire shape — see /v2/docs). + yield* Maple.Dashboard("service-health", { + name: "Service health", + description: "Golden signals for the checkout service", + tags: ["golden-signals"], + time_range: { type: "relative", value: "12h" }, + }) + + // A scoped CI key; the secret is minted once and kept in Alchemy state. + const ciKey = yield* Maple.ApiKey("ci-key", { + name: "ci-pipeline", + scopes: ["dashboards:write"], + }) + + // The org's ingest keys, as Redacted outputs for other resources. + const ingest = yield* Maple.IngestKeys("ingest") + + return { ciKeyId: ciKey.keyId, ingestRotatedAt: ingest.publicRotatedAt } + }), +) diff --git a/examples/alchemy-maple/package.json b/examples/alchemy-maple/package.json new file mode 100644 index 000000000..ff96a3c08 --- /dev/null +++ b/examples/alchemy-maple/package.json @@ -0,0 +1,12 @@ +{ + "name": "@maple-examples/alchemy", + "private": true, + "type": "module", + "description": "Example: declaring Maple resources (dashboards, alerts, keys) in alchemy.run.ts via @maple-dev/alchemy", + "devDependencies": { + "@maple-dev/alchemy": "workspace:*", + "alchemy": "2.0.0-beta.61", + "effect": "catalog:effect", + "typescript": "catalog:tooling" + } +} diff --git a/lib/alchemy-maple/README.md b/lib/alchemy-maple/README.md new file mode 100644 index 000000000..9c4daaae8 --- /dev/null +++ b/lib/alchemy-maple/README.md @@ -0,0 +1,88 @@ +# @maple-dev/alchemy + +[Alchemy](https://alchemy.run) provider for [Maple](https://maple.dev) resources. Declare Maple API keys, ingest keys, dashboards, alert destinations, and alert rules in your `alchemy.run.ts` — right next to the infrastructure they observe. + +```bash +npm install @maple-dev/alchemy alchemy effect +``` + +## Usage + +```typescript +import * as Alchemy from "alchemy" +import * as Cloudflare from "alchemy/Cloudflare" +import * as Maple from "@maple-dev/alchemy" +import { Effect, Layer } from "effect" + +export default Alchemy.Stack( + "my-app", + { providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()) }, + Effect.gen(function* () { + // A notification channel… + const slack = yield* Maple.AlertDestination("oncall", { + type: "slack", + name: "On-call Slack", + webhook_url: process.env.SLACK_WEBHOOK_URL!, + channel_label: "#incidents", + }) + + // …an alert rule that delivers to it (dependency resolved automatically)… + yield* Maple.AlertRule("checkout-errors", { + name: "Checkout error rate", + severity: "critical", + signal_type: "error_rate", + comparator: "gt", + threshold: 0.05, // error rates are 0–1 ratios + window_minutes: 5, + destination_ids: [slack.destinationId], + }) + + // …a dashboard… + yield* Maple.Dashboard("service-health", { + name: "Service health", + tags: ["golden-signals"], + time_range: { type: "relative", value: "12h" }, + }) + + // …a scoped API key (secret captured once, kept in Alchemy state)… + const key = yield* Maple.ApiKey("ci", { + name: "ci-pipeline", + scopes: ["dashboards:write"], + }) + + // …and your org's ingest keys as outputs for other resources. + const ingest = yield* Maple.IngestKeys("ingest") + // e.g. env: { MAPLE_INGEST_KEY: ingest.privateKey } + }), +) +``` + +```bash +MAPLE_API_KEY=maple_ak_… alchemy deploy +``` + +## Authentication + +Set `MAPLE_API_KEY` to a Maple API key (create one in the Maple dashboard, or with this provider). Requirements: + +- **Dashboards** need the `dashboards:write` scope. +- **Alert rules & destinations** need `alerts:write` **and** an org-admin key. +- **API keys & ingest keys** need `api_keys:write` / `ingest_keys:read` **and** an org-admin key. + +`MAPLE_API_URL` overrides the API base URL (defaults to `https://api.maple.dev`). To source configuration differently, provide your own `Maple.MapleEnvironment` layer. + +## Resources + +| Resource | Semantics | +| --- | --- | +| `Maple.Dashboard` | Full CRUD. Props are the v2 wire shape (`snake_case`, see `/v2/docs`); widget/variable documents pass through verbatim. Updates PATCH in place. | +| `Maple.AlertDestination` | Full CRUD. Discriminated on `type` (slack, pagerduty, webhook, hazel, discord, email). Channel secrets are write-only — accept `Redacted` values. Changing `type` replaces the destination. | +| `Maple.AlertRule` | Full CRUD. `destination_ids` accepts outputs from `Maple.AlertDestination`. Rule names are org-unique, so lost state is re-adopted by name instead of duplicating. | +| `Maple.ApiKey` | Create / roll / revoke (the API has no key update). Changing props replaces the key; bumping the `rotate` prop rolls it in place (same name/scopes, new secret). `secret` is captured once and preserved in Alchemy state — it can never be re-read from the API. | +| `Maple.IngestKeys` | Read-only per-org singleton. Surfaces `publicKey` / `privateKey` as `Redacted` outputs; delete only stops tracking it. | + +## Notes + +- Store your Alchemy state somewhere durable: the `ApiKey.secret` output lives only there. +- The client retries 429/5xx with bounded exponential backoff (the v2 API allows 600 requests per 60s per key). +- Deleting an `AlertDestination` fails with a conflict while alert rules still reference it — Alchemy's dependency ordering handles this automatically when the rule is declared in the same stack. diff --git a/lib/alchemy-maple/package.json b/lib/alchemy-maple/package.json new file mode 100644 index 000000000..544912346 --- /dev/null +++ b/lib/alchemy-maple/package.json @@ -0,0 +1,36 @@ +{ + "name": "@maple-dev/alchemy", + "version": "0.1.0", + "description": "Alchemy provider for Maple resources — declare API keys, ingest keys, dashboards, alert destinations, and alert rules in your alchemy.run.ts", + "license": "MIT", + "files": [ + "dist", + "README.md" + ], + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@effect/language-service": "catalog:effect", + "@maple/domain": "workspace:*", + "@types/node": "catalog:tooling", + "alchemy": "2.0.0-beta.61", + "effect": "catalog:effect", + "tsdown": "^0.21.7", + "typescript": "catalog:tooling", + "vitest": "catalog:" + }, + "peerDependencies": { + "alchemy": ">=2.0.0-beta.61 <3", + "effect": ">=4.0.0-beta.93 || >=4.0.0" + } +} diff --git a/lib/alchemy-maple/src/AlertDestination.ts b/lib/alchemy-maple/src/AlertDestination.ts new file mode 100644 index 000000000..21349cbdf --- /dev/null +++ b/lib/alchemy-maple/src/AlertDestination.ts @@ -0,0 +1,195 @@ +import { Schema } from "effect" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" +import { deepEqual, isResolved } from "alchemy/Diff" +import * as Provider from "alchemy/Provider" +import { Resource } from "alchemy/Resource" +import { listAll, MapleApi } from "./MapleApi" +import type { Providers } from "./Providers" + +/** A write-only channel secret: plain string or `Redacted` (recommended). */ +type SecretInput = string | Redacted.Redacted + +interface DestinationBaseProps { + /** Human-readable label for the destination. */ + name: string + /** Whether the destination starts enabled. Defaults to `true`. */ + enabled?: boolean +} + +/** + * Alert destination props, discriminated on `type` — mirrors the v2 + * `POST /v2/alerts/destinations` body. Channel secrets are write-only: + * the API never returns them, so drift on secret fields is detected from + * prop changes only. + */ +export type AlertDestinationProps = + | (DestinationBaseProps & { type: "slack"; webhook_url: SecretInput; channel_label?: string }) + | (DestinationBaseProps & { type: "pagerduty"; integration_key: SecretInput }) + | (DestinationBaseProps & { type: "webhook"; url: string; signing_secret?: SecretInput }) + | (DestinationBaseProps & { type: "hazel"; webhook_url: SecretInput; signing_secret?: SecretInput }) + | (DestinationBaseProps & { type: "discord"; webhook_url: SecretInput }) + | (DestinationBaseProps & { type: "email"; member_user_ids: string[] }) + +export type AlertDestination = Resource< + "Maple.AlertDestination", + AlertDestinationProps, + { + /** The `dest_…` public ID — reference it from `Maple.AlertRule` `destination_ids`. */ + destinationId: string + name: string + type: string + enabled: boolean + }, + never, + Providers +> + +/** + * A notification channel (Slack, PagerDuty, webhook, Hazel, Discord, or + * workspace-member email) that `Maple.AlertRule`s deliver to. + * + * @example + * ```typescript + * const slack = yield* Maple.AlertDestination("oncall", { + * type: "slack", + * name: "On-call Slack", + * webhook_url: Redacted.make(process.env.SLACK_WEBHOOK_URL!), + * channel_label: "#incidents", + * }) + * ``` + */ +export const AlertDestination = Resource("Maple.AlertDestination") + +const WireDestination = Schema.Struct({ + id: Schema.String, + name: Schema.String, + type: Schema.String, + enabled: Schema.Boolean, + channel_label: Schema.NullOr(Schema.String), +}) +const decodeWireDestination = Schema.decodeUnknownEffect(WireDestination) + +const unwrap = (value: SecretInput): string => (Redacted.isRedacted(value) ? Redacted.value(value) : value) + +/** The create/update body: all declared props, secrets unwrapped. */ +const desiredBody = (props: AlertDestinationProps): Record => { + const body: Record = { type: props.type, name: props.name } + if (props.enabled !== undefined) body.enabled = props.enabled + switch (props.type) { + case "slack": + body.webhook_url = unwrap(props.webhook_url) + if (props.channel_label !== undefined) body.channel_label = props.channel_label + break + case "pagerduty": + body.integration_key = unwrap(props.integration_key) + break + case "webhook": + body.url = props.url + if (props.signing_secret !== undefined) body.signing_secret = unwrap(props.signing_secret) + break + case "hazel": + body.webhook_url = unwrap(props.webhook_url) + if (props.signing_secret !== undefined) body.signing_secret = unwrap(props.signing_secret) + break + case "discord": + body.webhook_url = unwrap(props.webhook_url) + break + case "email": + body.member_user_ids = props.member_user_ids + break + } + return body +} + +/** + * Observable drift only — secrets are write-only, so the server can never + * disagree with them; they change via prop changes (caught in `diff`). + */ +const drifted = (props: AlertDestinationProps, observed: Schema.Schema.Type): boolean => + props.name !== observed.name || + (props.enabled ?? true) !== observed.enabled || + (props.type === "slack" && + props.channel_label !== undefined && + props.channel_label !== (observed.channel_label ?? undefined)) + +const toAttributes = (observed: Schema.Schema.Type) => ({ + destinationId: observed.id, + name: observed.name, + type: observed.type, + enabled: observed.enabled, +}) + +export const AlertDestinationProvider = () => + Provider.effect( + AlertDestination, + Effect.gen(function* () { + const api = yield* MapleApi + return { + stables: ["destinationId" as const], + diff: Effect.fn(function* ({ news, olds, output }) { + if (!isResolved(news)) return undefined + // `type` is immutable server-side — changing it replaces the destination. + if ((output?.type ?? olds?.type) !== undefined && news.type !== (output?.type ?? olds?.type)) { + return { action: "replace" } as const + } + if (olds !== undefined && !deepEqual(olds, news, { stripNullish: true })) { + return { action: "update", stables: ["destinationId"] } as const + } + return undefined + }), + reconcile: Effect.fn(function* ({ news, olds, output }) { + // Observe — re-fetch by id; recover from out-of-band deletes. + let observed: Schema.Schema.Type | undefined + if (output?.destinationId) { + const fetched = yield* api + .get(`/v2/alerts/destinations/${output.destinationId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched !== undefined) observed = yield* decodeWireDestination(fetched) + } + + // Ensure — create if missing. + if (observed === undefined) { + const created = yield* api.post("/v2/alerts/destinations", desiredBody(news)) + observed = yield* decodeWireDestination(created) + } else if ( + drifted(news, observed) || + olds === undefined || + !deepEqual(olds, news, { stripNullish: true }) + ) { + // Sync — PATCH when observable fields drift OR declared props changed + // (write-only secrets can only be pushed, never compared). + const updated = yield* api.patch( + `/v2/alerts/destinations/${observed.id}`, + desiredBody(news), + ) + observed = yield* decodeWireDestination(updated) + } + + return toAttributes(observed) + }), + delete: Effect.fn(function* ({ output }) { + yield* api + .delete(`/v2/alerts/destinations/${output.destinationId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + }), + read: Effect.fn(function* ({ output }) { + if (!output?.destinationId) return undefined + const fetched = yield* api + .get(`/v2/alerts/destinations/${output.destinationId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched === undefined) return undefined + return toAttributes(yield* decodeWireDestination(fetched)) + }), + list: Effect.fn(function* () { + const items = yield* listAll(api, "/v2/alerts/destinations") + return yield* Effect.forEach(items, (item) => + Effect.map(decodeWireDestination(item), toAttributes), + ) + }), + } + }), + ) + +/** @internal Exposed for the in-repo contract test against `@maple/domain`. */ +export const _alertDestinationCreateBody = desiredBody diff --git a/lib/alchemy-maple/src/AlertRule.ts b/lib/alchemy-maple/src/AlertRule.ts new file mode 100644 index 000000000..3cfe5d33f --- /dev/null +++ b/lib/alchemy-maple/src/AlertRule.ts @@ -0,0 +1,202 @@ +import { Schema } from "effect" +import * as Effect from "effect/Effect" +import { deepEqual, isResolved } from "alchemy/Diff" +import * as Provider from "alchemy/Provider" +import { Resource } from "alchemy/Resource" +import { listAll, MapleApi } from "./MapleApi" +import type { Providers } from "./Providers" + +export type AlertSignalType = + | "error_rate" + | "p95_latency" + | "p99_latency" + | "apdex" + | "throughput" + | "metric" + | "builder_query" + | "raw_query" + +export type AlertComparator = "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | "between" | "not_between" + +/** + * Alert rule props, authored in the v2 wire shape — mirrors + * `POST /v2/alerts/rules`. Signal-specific fields (`metric_*`, + * `apdex_threshold_ms`, `query_builder_draft`, `raw_query_*`) are validated + * server-side against `signal_type`. + */ +export interface AlertRuleProps { + /** Rule name — unique per organization. */ + name: string + severity: "warning" | "critical" + signal_type: AlertSignalType + comparator: AlertComparator + /** Error rates are 0–1 ratios. */ + threshold: number + window_minutes: number + /** `dest_…` IDs — pass `destination.destinationId` outputs from `Maple.AlertDestination`. */ + destination_ids: string[] + notes?: string | null + enabled?: boolean + service_names?: string[] + exclude_service_names?: string[] + tags?: string[] + group_by?: string[] | null + threshold_upper?: number | null + minimum_sample_count?: number + consecutive_breaches_required?: number + consecutive_healthy_required?: number + renotify_interval_minutes?: number + metric_name?: string | null + metric_type?: "sum" | "gauge" | "histogram" | "exponential_histogram" | null + metric_aggregation?: "avg" | "min" | "max" | "sum" | "count" | null + apdex_threshold_ms?: number | null + /** Opaque query-builder draft for `builder_query` rules (verbatim passthrough). */ + query_builder_draft?: Record | null + raw_query_sql?: string | null + raw_query_reducer?: "identity" | "sum" | "avg" | "min" | "max" | null + notification_template?: Record | null +} + +export type AlertRule = Resource< + "Maple.AlertRule", + AlertRuleProps, + { + /** The `alrt_…` public ID. */ + ruleId: string + name: string + enabled: boolean + }, + never, + Providers +> + +/** + * A Maple alert rule managed through the public v2 API. Reference the + * destinations it notifies by their `dest_…` IDs — typically outputs of + * `Maple.AlertDestination` resources, which Alchemy resolves and orders + * automatically. + * + * @example + * ```typescript + * const slack = yield* Maple.AlertDestination("oncall", { ... }) + * yield* Maple.AlertRule("checkout-errors", { + * name: "Checkout error rate", + * severity: "critical", + * signal_type: "error_rate", + * comparator: "gt", + * threshold: 0.05, + * window_minutes: 5, + * destination_ids: [slack.destinationId], + * }) + * ``` + */ +export const AlertRule = Resource("Maple.AlertRule") + +const WireRule = Schema.Struct({ + id: Schema.String, + name: Schema.String, + enabled: Schema.Boolean, +}) +const decodeWireRule = Schema.decodeUnknownEffect(WireRule) + +/** The create/update body: exactly the props the user declared. */ +const desiredBody = (props: AlertRuleProps): Record => { + const body: Record = {} + for (const [key, value] of Object.entries(props)) { + if (value !== undefined) body[key] = value + } + return body +} + +/** Wire drift compare: declared fields vs the observed wire rule. */ +const drifted = (props: AlertRuleProps, observed: Record): boolean => { + const body = desiredBody(props) + return Object.keys(body).some((key) => !deepEqual(body[key], observed[key], { stripNullish: true })) +} + +const toAttributes = (observed: Schema.Schema.Type) => ({ + ruleId: observed.id, + name: observed.name, + enabled: observed.enabled, +}) + +export const AlertRuleProvider = () => + Provider.effect( + AlertRule, + Effect.gen(function* () { + const api = yield* MapleApi + + /** Rule names are org-unique — adopt an existing rule with the same name. */ + const findByName = (name: string) => + Effect.gen(function* () { + const items = yield* listAll(api, "/v2/alerts/rules") + const match = items.find( + (item) => typeof item === "object" && item !== null && (item as { name?: unknown }).name === name, + ) + return match === undefined ? undefined : yield* decodeWireRule(match) + }) + + return { + stables: ["ruleId" as const], + diff: Effect.fn(function* ({ news, olds }) { + if (!isResolved(news)) return undefined + if (olds !== undefined && !deepEqual(olds, news, { stripNullish: true })) { + return { action: "update", stables: ["ruleId"] } as const + } + return undefined + }), + reconcile: Effect.fn(function* ({ news, output }) { + // Observe — re-fetch by id, falling back to the org-unique name so we + // recover from partial state-persistence failures without duplicating. + let observedRaw: unknown + if (output?.ruleId) { + observedRaw = yield* api + .get(`/v2/alerts/rules/${output.ruleId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + } + if (observedRaw === undefined) { + const adopted = yield* findByName(news.name) + if (adopted !== undefined) { + observedRaw = yield* api.get(`/v2/alerts/rules/${adopted.id}`) + } + } + + // Ensure — create if missing. + if (observedRaw === undefined) { + observedRaw = yield* api.post("/v2/alerts/rules", desiredBody(news)) + } else if (drifted(news, observedRaw as Record)) { + // Sync — PATCH only on drift of declared fields. + const current = yield* decodeWireRule(observedRaw) + observedRaw = yield* api.patch(`/v2/alerts/rules/${current.id}`, desiredBody(news)) + } + + return toAttributes(yield* decodeWireRule(observedRaw)) + }), + delete: Effect.fn(function* ({ output }) { + yield* api + .delete(`/v2/alerts/rules/${output.ruleId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + }), + read: Effect.fn(function* ({ olds, output }) { + if (output?.ruleId) { + const fetched = yield* api + .get(`/v2/alerts/rules/${output.ruleId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched !== undefined) return toAttributes(yield* decodeWireRule(fetched)) + } + if (olds?.name !== undefined) { + const adopted = yield* findByName(olds.name) + if (adopted !== undefined) return toAttributes(adopted) + } + return undefined + }), + list: Effect.fn(function* () { + const items = yield* listAll(api, "/v2/alerts/rules") + return yield* Effect.forEach(items, (item) => Effect.map(decodeWireRule(item), toAttributes)) + }), + } + }), + ) + +/** @internal Exposed for the in-repo contract test against `@maple/domain`. */ +export const _alertRuleCreateBody = desiredBody diff --git a/lib/alchemy-maple/src/ApiKey.ts b/lib/alchemy-maple/src/ApiKey.ts new file mode 100644 index 000000000..d8baab0ea --- /dev/null +++ b/lib/alchemy-maple/src/ApiKey.ts @@ -0,0 +1,186 @@ +import { Schema } from "effect" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" +import { deepEqual, isResolved } from "alchemy/Diff" +import * as Provider from "alchemy/Provider" +import { Resource } from "alchemy/Resource" +import { listAll, MapleApi } from "./MapleApi" +import type { Providers } from "./Providers" + +/** + * API key props — mirrors `POST /v2/api_keys`. The v2 API has no update + * endpoint for keys, so changing `name`/`description`/`scopes`/`kind`/ + * `expires_in_seconds` **replaces** the key (new ID + secret); bumping + * `rotate` rolls it in place via `POST /v2/api_keys/:id/roll` (preserves + * name/scopes, mints a new secret). + */ +export interface ApiKeyProps { + name: string + description?: string + /** e.g. `["dashboards:write", "alerts:read"]`; omit for full access. */ + scopes?: string[] + /** Defaults to `standard`. `mcp` keys are only valid for the MCP server. */ + kind?: "standard" | "mcp" + expires_in_seconds?: number + /** Bump (e.g. `1` → `2`) to roll the key: same name/scopes, new secret. */ + rotate?: number | string +} + +export type ApiKey = Resource< + "Maple.ApiKey", + ApiKeyProps, + { + /** The `key_…` public ID. */ + keyId: string + name: string + keyPrefix: string + /** + * The full `maple_ak_…` secret, captured at create/roll — the API never + * returns it again, so it is preserved in Alchemy state across deploys. + */ + secret: Redacted.Redacted + }, + never, + Providers +> + +/** + * A Maple API key managed through the public v2 API. Requires the deploy + * credential to be backed by an org admin. + * + * @example + * ```typescript + * const key = yield* Maple.ApiKey("ci", { + * name: "ci-pipeline", + * scopes: ["dashboards:write"], + * }) + * // key.secret is a Redacted — pass it into another resource's env. + * ``` + */ +export const ApiKey = Resource("Maple.ApiKey") + +const WireApiKey = Schema.Struct({ + id: Schema.String, + name: Schema.String, + key_prefix: Schema.String, + revoked: Schema.Boolean, +}) +const decodeWireApiKey = Schema.decodeUnknownEffect(WireApiKey) + +const WireApiKeyWithSecret = Schema.Struct({ + ...WireApiKey.fields, + secret: Schema.String, +}) +const decodeWireApiKeyWithSecret = Schema.decodeUnknownEffect(WireApiKeyWithSecret) + +const createBody = (props: ApiKeyProps): Record => { + const body: Record = { name: props.name } + if (props.description !== undefined) body.description = props.description + if (props.scopes !== undefined) body.scopes = props.scopes + if (props.kind !== undefined) body.kind = props.kind + if (props.expires_in_seconds !== undefined) body.expires_in_seconds = props.expires_in_seconds + return body +} + +const fromSecretResponse = (wire: Schema.Schema.Type) => ({ + keyId: wire.id, + name: wire.name, + keyPrefix: wire.key_prefix, + secret: Redacted.make(wire.secret), +}) + +export const ApiKeyProvider = () => + Provider.effect( + ApiKey, + Effect.gen(function* () { + const api = yield* MapleApi + return { + diff: Effect.fn(function* ({ news, olds }) { + if (!isResolved(news)) return undefined + if (olds === undefined) return undefined + const { rotate: oldRotate, ...oldRest } = olds + const { rotate: newRotate, ...newRest } = news + // No PATCH endpoint: any non-rotate change replaces the key. + if (!deepEqual(oldRest, newRest, { stripNullish: true })) { + return { action: "replace" } as const + } + if (oldRotate !== newRotate) { + return { action: "update", stables: [] } as const + } + return undefined + }), + reconcile: Effect.fn(function* ({ news, olds, output }) { + // Observe — confirm the key still exists and is not revoked. + let observed: Schema.Schema.Type | undefined + if (output?.keyId) { + const fetched = yield* api + .get(`/v2/api_keys/${output.keyId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched !== undefined) { + const wire = yield* decodeWireApiKey(fetched) + if (!wire.revoked) observed = wire + } + } + + // Ensure — create if missing/revoked. The secret is returned exactly + // once; it lives in Alchemy state from here on. + if (observed === undefined || output === undefined) { + const created = yield* api.post("/v2/api_keys", createBody(news)) + return fromSecretResponse(yield* decodeWireApiKeyWithSecret(created)) + } + + // Roll — `rotate` bumped: replace the secret in place. + if (olds !== undefined && olds.rotate !== news.rotate) { + const rolled = yield* api.post(`/v2/api_keys/${observed.id}/roll`) + return fromSecretResponse(yield* decodeWireApiKeyWithSecret(rolled)) + } + + // Steady state — preserve the stored secret (GET never returns it). + return { + keyId: observed.id, + name: observed.name, + keyPrefix: observed.key_prefix, + secret: output.secret, + } + }), + delete: Effect.fn(function* ({ output }) { + yield* api + .delete(`/v2/api_keys/${output.keyId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + }), + read: Effect.fn(function* ({ output }) { + if (!output?.keyId) return undefined + const fetched = yield* api + .get(`/v2/api_keys/${output.keyId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched === undefined) return undefined + const wire = yield* decodeWireApiKey(fetched) + if (wire.revoked) return undefined + return { + keyId: wire.id, + name: wire.name, + keyPrefix: wire.key_prefix, + // The API never returns the secret; keep the state's copy. + secret: output.secret, + } + }), + list: Effect.fn(function* () { + const items = yield* listAll(api, "/v2/api_keys") + const wires = yield* Effect.forEach(items, (item) => decodeWireApiKey(item)) + // Secrets are unrecoverable via list — empty placeholder (list only + // powers enumeration/nuke, which needs IDs, not secrets). + return wires + .filter((wire) => !wire.revoked) + .map((wire) => ({ + keyId: wire.id, + name: wire.name, + keyPrefix: wire.key_prefix, + secret: Redacted.make(""), + })) + }), + } + }), + ) + +/** @internal Exposed for the in-repo contract test against `@maple/domain`. */ +export const _apiKeyCreateBody = createBody diff --git a/lib/alchemy-maple/src/Dashboard.ts b/lib/alchemy-maple/src/Dashboard.ts new file mode 100644 index 000000000..ea07781b4 --- /dev/null +++ b/lib/alchemy-maple/src/Dashboard.ts @@ -0,0 +1,145 @@ +import { Schema } from "effect" +import * as Effect from "effect/Effect" +import { deepEqual, isResolved } from "alchemy/Diff" +import * as Provider from "alchemy/Provider" +import { Resource } from "alchemy/Resource" +import { listAll, MapleApi } from "./MapleApi" +import type { Providers } from "./Providers" + +/** + * Dashboard props, authored in the v2 wire shape (snake_case, exactly as + * documented at `/v2/docs`). `widgets`, `variables`, and `time_range` are + * passed through verbatim. + */ +export interface DashboardProps { + /** Dashboard name (unique-ish label shown in the UI). */ + name: string + description?: string | null + tags?: string[] + /** e.g. `{ type: "relative", value: "12h" }`. */ + time_range?: Record + widgets?: Array> + variables?: Array> +} + +export type Dashboard = Resource< + "Maple.Dashboard", + DashboardProps, + { + /** The `dash_…` public ID. */ + dashboardId: string + name: string + }, + never, + Providers +> + +/** + * A Maple dashboard managed through the public v2 API. + * + * @example + * ```typescript + * const dash = yield* Maple.Dashboard("service-health", { + * name: "Service health", + * widgets: [...], + * }) + * ``` + */ +export const Dashboard = Resource("Maple.Dashboard") + +/** Decode just the wire fields the provider stores/compares. */ +const WireDashboard = Schema.Struct({ + id: Schema.String, + name: Schema.String, + description: Schema.NullOr(Schema.String), + tags: Schema.Array(Schema.String), + time_range: Schema.Record(Schema.String, Schema.Unknown), + widgets: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), + variables: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), +}) +const decodeWireDashboard = Schema.decodeUnknownEffect(WireDashboard) + +/** The request body for create/update: exactly the props the user set. */ +const desiredBody = (props: DashboardProps) => ({ + name: props.name, + ...(props.description !== undefined ? { description: props.description } : {}), + ...(props.tags !== undefined ? { tags: props.tags } : {}), + ...(props.time_range !== undefined ? { time_range: props.time_range } : {}), + ...(props.widgets !== undefined ? { widgets: props.widgets } : {}), + ...(props.variables !== undefined ? { variables: props.variables } : {}), +}) + +/** Compare only the fields the user declared against the observed wire object. */ +const drifted = (props: DashboardProps, observed: Schema.Schema.Type): boolean => { + const body = desiredBody(props) as Record + return Object.keys(body).some( + (key) => !deepEqual(body[key], (observed as unknown as Record)[key], { stripNullish: true }), + ) +} + +const toAttributes = (observed: Schema.Schema.Type) => ({ + dashboardId: observed.id, + name: observed.name, +}) + +export const DashboardProvider = () => + Provider.effect( + Dashboard, + Effect.gen(function* () { + const api = yield* MapleApi + return { + stables: ["dashboardId" as const], + diff: Effect.fn(function* ({ news, olds }) { + if (!isResolved(news)) return undefined + if (olds !== undefined && !deepEqual(olds, news, { stripNullish: true })) { + return { action: "update", stables: ["dashboardId"] } as const + } + return undefined + }), + reconcile: Effect.fn(function* ({ news, output }) { + // Observe — re-fetch by id; recover from out-of-band deletes. + let observed: Schema.Schema.Type | undefined + if (output?.dashboardId) { + const fetched = yield* api + .get(`/v2/dashboards/${output.dashboardId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched !== undefined) observed = yield* decodeWireDashboard(fetched) + } + + // Ensure — create if missing. + if (observed === undefined) { + const created = yield* api.post("/v2/dashboards", desiredBody(news)) + observed = yield* decodeWireDashboard(created) + } else if (drifted(news, observed)) { + // Sync — PATCH only when the declared fields drift. + const updated = yield* api.patch(`/v2/dashboards/${observed.id}`, desiredBody(news)) + observed = yield* decodeWireDashboard(updated) + } + + return toAttributes(observed) + }), + delete: Effect.fn(function* ({ output }) { + yield* api + .delete(`/v2/dashboards/${output.dashboardId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.void)) + }), + read: Effect.fn(function* ({ output }) { + if (!output?.dashboardId) return undefined + const fetched = yield* api + .get(`/v2/dashboards/${output.dashboardId}`) + .pipe(Effect.catchTag("Maple::NotFoundError", () => Effect.succeed(undefined))) + if (fetched === undefined) return undefined + return toAttributes(yield* decodeWireDashboard(fetched)) + }), + list: Effect.fn(function* () { + const items = yield* listAll(api, "/v2/dashboards") + return yield* Effect.forEach(items, (item) => + Effect.map(decodeWireDashboard(item), toAttributes), + ) + }), + } + }), + ) + +/** @internal Exposed for the in-repo contract test against `@maple/domain`. */ +export const _dashboardCreateBody = desiredBody diff --git a/lib/alchemy-maple/src/IngestKeys.ts b/lib/alchemy-maple/src/IngestKeys.ts new file mode 100644 index 000000000..fbf2c156f --- /dev/null +++ b/lib/alchemy-maple/src/IngestKeys.ts @@ -0,0 +1,78 @@ +import { Schema } from "effect" +import * as Effect from "effect/Effect" +import * as Redacted from "effect/Redacted" +import * as Provider from "alchemy/Provider" +import { Resource } from "alchemy/Resource" +import { MapleApi } from "./MapleApi" +import type { Providers } from "./Providers" + +/** + * The org's telemetry ingest keys — a per-organization **singleton** the API + * creates lazily on first access. This resource only observes it (making the + * keys available as outputs); rolling is deliberately not modeled yet since + * a roll immediately invalidates the previous key for all senders. + */ +export interface IngestKeysProps {} + +export type IngestKeys = Resource< + "Maple.IngestKeys", + IngestKeysProps, + { + /** The `maple_pk_…` public ingest key (safe for client-side senders). */ + publicKey: Redacted.Redacted + /** The `maple_sk_…` private ingest key (server-side senders only). */ + privateKey: Redacted.Redacted + publicRotatedAt: string + privateRotatedAt: string + }, + never, + Providers +> + +/** + * Your organization's ingest credentials, surfaced as resource outputs so + * they can flow into other resources (e.g. a Worker's env). + * + * @example + * ```typescript + * const ingest = yield* Maple.IngestKeys("ingest") + * // ingest.publicKey / ingest.privateKey are Redacted outputs. + * ``` + */ +export const IngestKeys = Resource("Maple.IngestKeys") + +const WireIngestKeys = Schema.Struct({ + public_key: Schema.String, + private_key: Schema.String, + public_rotated_at: Schema.String, + private_rotated_at: Schema.String, +}) +const decodeWireIngestKeys = Schema.decodeUnknownEffect(WireIngestKeys) + +const toAttributes = (wire: Schema.Schema.Type) => ({ + publicKey: Redacted.make(wire.public_key), + privateKey: Redacted.make(wire.private_key), + publicRotatedAt: wire.public_rotated_at, + privateRotatedAt: wire.private_rotated_at, +}) + +export const IngestKeysProvider = () => + Provider.effect( + IngestKeys, + Effect.gen(function* () { + const api = yield* MapleApi + const fetchKeys = Effect.gen(function* () { + const fetched = yield* api.get("/v2/ingest_keys") + return toAttributes(yield* decodeWireIngestKeys(fetched)) + }) + return { + // Always-present org configuration: deleting the resource only stops + // tracking it, and nuke skips it entirely. + nuke: { singleton: true }, + reconcile: () => fetchKeys, + delete: () => Effect.void, + read: () => fetchKeys, + list: () => Effect.map(fetchKeys, (attributes) => [attributes]), + } + }), + ) diff --git a/lib/alchemy-maple/src/MapleApi.ts b/lib/alchemy-maple/src/MapleApi.ts new file mode 100644 index 000000000..32bda353e --- /dev/null +++ b/lib/alchemy-maple/src/MapleApi.ts @@ -0,0 +1,155 @@ +import * as Context from "effect/Context" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Redacted from "effect/Redacted" +import * as Schedule from "effect/Schedule" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { + MapleApiError, + MapleConflictError, + MapleNotFoundError, + MapleUnauthorizedError, + type MapleError, +} from "./errors" +import { MapleEnvironment } from "./MapleEnvironment" + +/** + * Thin JSON client for the Maple public v2 API. + * + * Providers call these instead of a generated client so the package ships + * with zero runtime dependencies beyond `effect`. Responses are returned as + * parsed JSON (`unknown`); each provider decodes just the fields it needs. + * + * Known statuses map to typed errors (404 / 409 / 401·403); everything else + * non-2xx is a {@link MapleApiError}. 429s and 5xx are retried with bounded + * exponential backoff (the v2 API allows 600 requests per 60s per key). + */ +export interface MapleApiShape { + readonly get: (path: string) => Effect.Effect + readonly post: (path: string, body?: unknown) => Effect.Effect + readonly patch: (path: string, body: unknown) => Effect.Effect + readonly delete: (path: string) => Effect.Effect +} + +export class MapleApi extends Context.Service()("Maple::Api") {} + +const errorFromResponse = (status: number, bodyText: string): MapleError => { + let message = `Maple API request failed with status ${status}` + let errorType: string | undefined + let code: string | undefined + try { + const parsed = JSON.parse(bodyText) as { error?: { type?: string; code?: string; message?: string } } + if (parsed?.error?.message) message = parsed.error.message + errorType = parsed?.error?.type + code = parsed?.error?.code + } catch { + if (bodyText.length > 0) message = `${message}: ${bodyText.slice(0, 200)}` + } + const fields = { + status, + message, + ...(errorType !== undefined ? { errorType } : {}), + ...(code !== undefined ? { code } : {}), + } + if (status === 404) return new MapleNotFoundError(fields) + if (status === 409) return new MapleConflictError(fields) + if (status === 401 || status === 403) return new MapleUnauthorizedError(fields) + return new MapleApiError(fields) +} + +const isRetryable = (error: MapleError): boolean => + error._tag === "Maple::ApiError" && (error.status === 429 || error.status >= 500) + +const retryPolicy = Schedule.exponential(Duration.millis(500), 2).pipe( + Schedule.either(Schedule.spaced(Duration.seconds(10))), + Schedule.both(Schedule.recurs(6)), +) + +export const make = Effect.gen(function* () { + const { baseUrl, apiKey } = yield* MapleEnvironment + const httpClient = yield* HttpClient.HttpClient + + const request = (method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown) => + Effect.gen(function* () { + let req = HttpClientRequest.make(method)(`${baseUrl}${path}`).pipe( + HttpClientRequest.setHeaders({ + Authorization: `Bearer ${Redacted.value(apiKey)}`, + Accept: "application/json", + }), + ) + if (body !== undefined) { + req = yield* HttpClientRequest.bodyJson(req, body).pipe( + Effect.mapError( + (error) => + new MapleApiError({ + status: 0, + message: `Failed to encode request body: ${String(error)}`, + }), + ), + ) + } + const response = yield* httpClient.execute(req).pipe( + Effect.mapError( + (error) => new MapleApiError({ status: 0, message: `Maple API request failed: ${error.message}` }), + ), + ) + // Drain the body either way so the connection is released. + const text = yield* response.text.pipe( + Effect.mapError( + (error) => + new MapleApiError({ status: response.status, message: `Failed to read response: ${error.message}` }), + ), + ) + if (response.status >= 200 && response.status < 300) { + if (text.length === 0) return undefined as unknown + return yield* Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: () => + new MapleApiError({ + status: response.status, + message: `Maple API returned invalid JSON (status ${response.status})`, + }), + }) + } + return yield* Effect.fail(errorFromResponse(response.status, text)) + }).pipe( + Effect.retry({ + schedule: retryPolicy, + while: isRetryable, + }), + ) + + return { + get: (path: string) => request("GET", path), + post: (path: string, body?: unknown) => request("POST", path, body), + patch: (path: string, body: unknown) => request("PATCH", path, body), + delete: (path: string) => request("DELETE", path), + } +}) + +/** Live client: {@link MapleEnvironment} + the runtime's global `fetch`. */ +export const MapleApiLive = () => + Layer.effect(MapleApi, make).pipe(Layer.provide(FetchHttpClient.layer)) + +/** + * Fetch every page of a v2 list endpoint (`{ object: "list", data, has_more, + * next_cursor }`), following cursors until exhausted. + */ +export const listAll = ( + api: MapleApiShape, + path: string, +): Effect.Effect, MapleError> => + Effect.gen(function* () { + const items: Array = [] + let cursor: string | null = null + do { + const sep = path.includes("?") ? "&" : "?" + const page = (yield* api.get( + cursor === null ? `${path}${sep}limit=100` : `${path}${sep}limit=100&cursor=${encodeURIComponent(cursor)}`, + )) as { data?: ReadonlyArray; has_more?: boolean; next_cursor?: string | null } + items.push(...(page.data ?? [])) + cursor = page.has_more === true && typeof page.next_cursor === "string" ? page.next_cursor : null + } while (cursor !== null) + return items + }) diff --git a/lib/alchemy-maple/src/MapleEnvironment.ts b/lib/alchemy-maple/src/MapleEnvironment.ts new file mode 100644 index 000000000..d71e7c12f --- /dev/null +++ b/lib/alchemy-maple/src/MapleEnvironment.ts @@ -0,0 +1,45 @@ +import * as Config from "effect/Config" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import type * as Redacted from "effect/Redacted" + +/** + * Connection settings for the Maple public v2 API. + * + * Every Maple provider resolves its base URL and credential from this + * service. `Maple.providers()` wires it from the environment by default + * ({@link fromEnv}); provide your own layer to point at a different + * deployment or to source the key from elsewhere. + */ +export class MapleEnvironment extends Context.Service< + MapleEnvironment, + { + /** Base URL of the Maple API, without a trailing slash. */ + readonly baseUrl: string + /** A Maple API key (`maple_ak_…`) with the scopes the declared resources need. */ + readonly apiKey: Redacted.Redacted + } +>()("Maple::Environment") {} + +export const DEFAULT_BASE_URL = "https://api.maple.dev" + +/** + * Resolve the Maple environment from process env: + * + * - `MAPLE_API_KEY` (required) — a `maple_ak_…` API key. Mutating API keys, + * ingest keys, and alert rules/destinations requires a key backed by an + * org-admin; dashboards need the `dashboards:write` scope. + * - `MAPLE_API_URL` (optional) — defaults to `https://api.maple.dev`. + */ +export const fromEnv = () => + Layer.effect( + MapleEnvironment, + Effect.gen(function* () { + const apiKey = yield* Config.redacted("MAPLE_API_KEY") + const baseUrl = yield* Config.string("MAPLE_API_URL").pipe( + Config.withDefault(DEFAULT_BASE_URL), + ) + return { baseUrl: baseUrl.replace(/\/+$/, ""), apiKey } + }).pipe(Effect.orDie), + ) diff --git a/lib/alchemy-maple/src/Providers.ts b/lib/alchemy-maple/src/Providers.ts new file mode 100644 index 000000000..293bb8432 --- /dev/null +++ b/lib/alchemy-maple/src/Providers.ts @@ -0,0 +1,43 @@ +import * as Layer from "effect/Layer" +import * as Provider from "alchemy/Provider" +import { AlertDestination, AlertDestinationProvider } from "./AlertDestination" +import { AlertRule, AlertRuleProvider } from "./AlertRule" +import { ApiKey, ApiKeyProvider } from "./ApiKey" +import { Dashboard, DashboardProvider } from "./Dashboard" +import { IngestKeys, IngestKeysProvider } from "./IngestKeys" +import { MapleApiLive } from "./MapleApi" +import * as MapleEnvironment from "./MapleEnvironment" + +export class Providers extends Provider.ProviderCollection()("Maple") {} + +/** + * The Maple provider collection. Merge it into your stack's `providers` + * layer alongside any others: + * + * ```typescript + * export default Alchemy.Stack("my-app", { + * providers: Layer.mergeAll(Cloudflare.providers(), Maple.providers()), + * }, Effect.gen(function* () { ... })) + * ``` + * + * Credentials come from `MAPLE_API_KEY` / `MAPLE_API_URL` by default; provide + * your own {@link MapleEnvironment.MapleEnvironment} layer to override. + */ +export const providers = () => + Layer.effect( + Providers, + Provider.collection([ApiKey, Dashboard, AlertDestination, AlertRule, IngestKeys]), + ).pipe( + Layer.provide( + Layer.mergeAll( + ApiKeyProvider(), + DashboardProvider(), + AlertDestinationProvider(), + AlertRuleProvider(), + IngestKeysProvider(), + ), + ), + Layer.provide(MapleApiLive()), + Layer.provide(MapleEnvironment.fromEnv()), + Layer.orDie, + ) diff --git a/lib/alchemy-maple/src/errors.ts b/lib/alchemy-maple/src/errors.ts new file mode 100644 index 000000000..27f81f445 --- /dev/null +++ b/lib/alchemy-maple/src/errors.ts @@ -0,0 +1,36 @@ +import { Schema } from "effect" + +/** + * Typed failures surfaced by the Maple API client. The v2 error envelope is + * `{ error: { type, code, message, param? } }`; the client maps well-known + * statuses to dedicated tags so providers can `catchTag` (404 → adopt/recreate, + * 409 → adopt-by-name) and leaves everything else on `MapleApiError`. + */ + +const errorFields = { + status: Schema.Number, + message: Schema.String, + /** The v2 envelope `error.type`, when the body carried one. */ + errorType: Schema.optionalKey(Schema.String), + /** The v2 envelope `error.code`, when the body carried one. */ + code: Schema.optionalKey(Schema.String), +} + +export class MapleApiError extends Schema.TaggedErrorClass()("Maple::ApiError", errorFields) {} + +export class MapleNotFoundError extends Schema.TaggedErrorClass()( + "Maple::NotFoundError", + errorFields, +) {} + +export class MapleConflictError extends Schema.TaggedErrorClass()( + "Maple::ConflictError", + errorFields, +) {} + +export class MapleUnauthorizedError extends Schema.TaggedErrorClass()( + "Maple::UnauthorizedError", + errorFields, +) {} + +export type MapleError = MapleApiError | MapleNotFoundError | MapleConflictError | MapleUnauthorizedError diff --git a/lib/alchemy-maple/src/index.ts b/lib/alchemy-maple/src/index.ts new file mode 100644 index 000000000..89a6f0fab --- /dev/null +++ b/lib/alchemy-maple/src/index.ts @@ -0,0 +1,55 @@ +/** + * Alchemy provider for Maple resources. + * + * Declare Maple API keys, ingest keys, dashboards, alert destinations, and + * alert rules in your `alchemy.run.ts`, authenticated with a `maple_ak_…` + * API key against the Maple public v2 API. + * + * @example + * ```typescript + * import * as Alchemy from "alchemy" + * import * as Maple from "@maple-dev/alchemy" + * import { Effect, Layer } from "effect" + * + * export default Alchemy.Stack("my-app", { + * providers: Maple.providers(), + * }, Effect.gen(function* () { + * const slack = yield* Maple.AlertDestination("oncall", { + * type: "slack", + * name: "On-call Slack", + * webhook_url: process.env.SLACK_WEBHOOK_URL!, + * }) + * yield* Maple.AlertRule("checkout-errors", { + * name: "Checkout error rate", + * severity: "critical", + * signal_type: "error_rate", + * comparator: "gt", + * threshold: 0.05, + * window_minutes: 5, + * destination_ids: [slack.destinationId], + * }) + * })) + * ``` + */ + +export { AlertDestination, AlertDestinationProvider, type AlertDestinationProps } from "./AlertDestination" +export { + AlertRule, + AlertRuleProvider, + type AlertComparator, + type AlertRuleProps, + type AlertSignalType, +} from "./AlertRule" +export { ApiKey, ApiKeyProvider, type ApiKeyProps } from "./ApiKey" +export { Dashboard, DashboardProvider, type DashboardProps } from "./Dashboard" +export { + MapleApiError, + MapleConflictError, + MapleNotFoundError, + MapleUnauthorizedError, + type MapleError, +} from "./errors" +export { IngestKeys, IngestKeysProvider, type IngestKeysProps } from "./IngestKeys" +export { listAll, MapleApi, MapleApiLive, type MapleApiShape } from "./MapleApi" +export { DEFAULT_BASE_URL, fromEnv, MapleEnvironment } from "./MapleEnvironment" +export { Providers, providers } from "./Providers" diff --git a/lib/alchemy-maple/test/contract.test.ts b/lib/alchemy-maple/test/contract.test.ts new file mode 100644 index 000000000..37dc9706c --- /dev/null +++ b/lib/alchemy-maple/test/contract.test.ts @@ -0,0 +1,106 @@ +/** + * Contract tests: the provider's hand-written wire bodies/decoders must stay + * compatible with the real `@maple/domain` v2 schemas (dev-only dependency — + * never shipped). If a v2 schema changes shape, these fail in CI. + */ +import { describe, expect, it } from "vitest" +import { Effect, Schema } from "effect" +import { + V2AlertDestinationCreateParams, + V2AlertRuleCreateParams, + V2ApiKeyCreateParams, + V2DashboardCreateParams, +} from "@maple/domain/http/v2" +import { _alertDestinationCreateBody } from "../src/AlertDestination" +import { _alertRuleCreateBody } from "../src/AlertRule" +import { _apiKeyCreateBody } from "../src/ApiKey" +import { _dashboardCreateBody } from "../src/Dashboard" + +const decodes = >(schema: S, wire: unknown) => + Effect.runSync(Schema.decodeUnknownEffect(schema)(wire).pipe(Effect.asVoid)) + +describe("provider request bodies decode against the real v2 create-param schemas", () => { + it("dashboard create body", () => { + const body = _dashboardCreateBody({ + name: "Service health", + description: "Golden signals", + tags: ["golden"], + time_range: { type: "relative", value: "12h" }, + widgets: [ + { + id: "w1", + visualization: "timeseries", + data_source: { endpoint: "query_builder", params: { granularity_seconds: 60 } }, + display: { title: "Throughput" }, + layout: { x: 0, y: 0, w: 6, h: 4 }, + }, + ], + variables: [{ name: "service", type: "textbox" }], + }) + expect(() => decodes(V2DashboardCreateParams, body)).not.toThrow() + }) + + it("alert destination create bodies (each channel type)", () => { + const bodies = [ + _alertDestinationCreateBody({ + type: "slack", + name: "On-call Slack", + webhook_url: "https://hooks.slack.com/services/T000/B000/XXXX", + channel_label: "#incidents", + enabled: true, + }), + _alertDestinationCreateBody({ type: "pagerduty", name: "PD", integration_key: "key" }), + _alertDestinationCreateBody({ + type: "webhook", + name: "Hook", + url: "https://example.com/hooks/maple", + signing_secret: "shh", + }), + _alertDestinationCreateBody({ + type: "hazel", + name: "Hazel", + webhook_url: "https://hazel.example.com/hook", + }), + _alertDestinationCreateBody({ + type: "discord", + name: "Discord", + webhook_url: "https://discord.com/api/webhooks/x", + }), + _alertDestinationCreateBody({ + type: "email", + name: "Email", + member_user_ids: ["user_2Nk8mXqPfR3yZ1aB4cD5eF6g"], + }), + ] + for (const body of bodies) { + expect(() => decodes(V2AlertDestinationCreateParams, body)).not.toThrow() + } + }) + + it("alert rule create body", () => { + const body = _alertRuleCreateBody({ + name: "Checkout error rate", + severity: "critical", + signal_type: "error_rate", + comparator: "gt", + threshold: 0.05, + window_minutes: 5, + destination_ids: ["dest_oybbpTBhtSFGShMjjLiCrh"], + service_names: ["checkout"], + tags: ["payments"], + minimum_sample_count: 50, + }) + expect(() => decodes(V2AlertRuleCreateParams, body)).not.toThrow() + }) + + it("api key create body", () => { + const body = _apiKeyCreateBody({ + name: "ci-pipeline", + description: "Publishes deploys", + scopes: ["dashboards:write", "alerts:read"], + kind: "standard", + expires_in_seconds: 7_776_000, + }) + expect(() => decodes(V2ApiKeyCreateParams, body)).not.toThrow() + }) +}) diff --git a/lib/alchemy-maple/test/providers.test.ts b/lib/alchemy-maple/test/providers.test.ts new file mode 100644 index 000000000..3f75ede7d --- /dev/null +++ b/lib/alchemy-maple/test/providers.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest" +import { Effect, Layer, Redacted } from "effect" +import type { ScopedPlanStatusSession } from "alchemy/Cli/Cli" +import { ApiKey, ApiKeyProvider } from "../src/ApiKey" +import { Dashboard, DashboardProvider } from "../src/Dashboard" +import { MapleApi, type MapleApiShape } from "../src/MapleApi" +import { MapleNotFoundError, type MapleError } from "../src/errors" + +/** In-memory stub of the v2 API: canned responses + a call log. */ +const makeStub = ( + routes: Record Effect.Effect>, +): { api: MapleApiShape; calls: Array } => { + const calls: Array = [] + const dispatch = (method: string, path: string, body?: unknown) => { + calls.push(`${method} ${path}`) + const handler = routes[`${method} ${path}`] + if (handler === undefined) { + return Effect.fail(new MapleNotFoundError({ status: 404, message: `no route: ${method} ${path}` })) + } + return handler(body) + } + return { + calls, + api: { + get: (path) => dispatch("GET", path), + post: (path, body) => dispatch("POST", path, body), + patch: (path, body) => dispatch("PATCH", path, body), + delete: (path) => dispatch("DELETE", path), + }, + } +} + +const session: ScopedPlanStatusSession = { + emit: () => Effect.void, + done: () => Effect.void, + note: () => Effect.void, +} + +const wireDashboard = { + id: "dash_abc", + object: "dashboard", + name: "Service health", + description: null, + tags: [], + time_range: { type: "relative", value: "12h" }, + widgets: [], + variables: [], + created_at: "2026-07-01T12:00:00.000Z", + updated_at: "2026-07-01T12:00:00.000Z", +} + +const runWithProvider = ( + api: MapleApiShape, + program: Effect.Effect, +): Promise => + Effect.runPromise( + program.pipe( + Effect.provide(DashboardProvider().pipe(Layer.provide(Layer.succeed(MapleApi, api)))), + Effect.provide(ApiKeyProvider().pipe(Layer.provide(Layer.succeed(MapleApi, api)))), + ) as Effect.Effect, + ) + +describe("DashboardProvider", () => { + it("creates when there is no prior state", async () => { + const stub = makeStub({ + "POST /v2/dashboards": () => Effect.succeed(wireDashboard), + }) + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + return yield* provider.reconcile({ + id: "service-health", + instanceId: "i-1", + news: { name: "Service health" }, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + }), + ) + expect(attributes).toEqual({ dashboardId: "dash_abc", name: "Service health" }) + expect(stub.calls).toEqual(["POST /v2/dashboards"]) + }) + + it("observes without mutating when nothing drifted", async () => { + const stub = makeStub({ + "GET /v2/dashboards/dash_abc": () => Effect.succeed(wireDashboard), + }) + await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + return yield* provider.reconcile({ + id: "service-health", + instanceId: "i-1", + news: { name: "Service health" }, + olds: { name: "Service health" }, + output: { dashboardId: "dash_abc", name: "Service health" }, + session, + bindings: [], + }) + }), + ) + expect(stub.calls).toEqual(["GET /v2/dashboards/dash_abc"]) + }) + + it("patches when a declared field drifted", async () => { + const stub = makeStub({ + "GET /v2/dashboards/dash_abc": () => Effect.succeed(wireDashboard), + "PATCH /v2/dashboards/dash_abc": (body) => + Effect.succeed({ ...wireDashboard, ...(body as object) }), + }) + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + return yield* provider.reconcile({ + id: "service-health", + instanceId: "i-1", + news: { name: "Renamed", tags: ["golden"] }, + olds: { name: "Service health" }, + output: { dashboardId: "dash_abc", name: "Service health" }, + session, + bindings: [], + }) + }), + ) + expect(attributes.name).toBe("Renamed") + expect(stub.calls).toEqual(["GET /v2/dashboards/dash_abc", "PATCH /v2/dashboards/dash_abc"]) + }) + + it("recreates after an out-of-band delete", async () => { + const stub = makeStub({ + "POST /v2/dashboards": () => Effect.succeed(wireDashboard), + }) + await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + return yield* provider.reconcile({ + id: "service-health", + instanceId: "i-1", + news: { name: "Service health" }, + olds: { name: "Service health" }, + output: { dashboardId: "dash_gone", name: "Service health" }, + session, + bindings: [], + }) + }), + ) + expect(stub.calls).toEqual(["GET /v2/dashboards/dash_gone", "POST /v2/dashboards"]) + }) + + it("delete tolerates 404", async () => { + const stub = makeStub({}) + await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* Dashboard.Provider + yield* provider.delete({ + id: "service-health", + instanceId: "i-1", + olds: { name: "Service health" }, + output: { dashboardId: "dash_gone", name: "Service health" }, + session, + bindings: [], + }) + }), + ) + expect(stub.calls).toEqual(["DELETE /v2/dashboards/dash_gone"]) + }) +}) + +const wireApiKey = { + id: "key_abc", + object: "api_key", + name: "ci", + key_prefix: "maple_ak_9f2c", + revoked: false, +} + +describe("ApiKeyProvider", () => { + it("captures the one-time secret on create", async () => { + const stub = makeStub({ + "POST /v2/api_keys": () => Effect.succeed({ ...wireApiKey, secret: "maple_ak_secret1" }), + }) + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* ApiKey.Provider + return yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci" }, + olds: undefined, + output: undefined, + session, + bindings: [], + }) + }), + ) + expect(Redacted.value(attributes.secret)).toBe("maple_ak_secret1") + }) + + it("preserves the stored secret on steady-state reconcile", async () => { + const stub = makeStub({ + "GET /v2/api_keys/key_abc": () => Effect.succeed(wireApiKey), + }) + const secret = Redacted.make("maple_ak_secret1") + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* ApiKey.Provider + return yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci" }, + olds: { name: "ci" }, + output: { keyId: "key_abc", name: "ci", keyPrefix: "maple_ak_9f2c", secret }, + session, + bindings: [], + }) + }), + ) + expect(Redacted.value(attributes.secret)).toBe("maple_ak_secret1") + expect(stub.calls).toEqual(["GET /v2/api_keys/key_abc"]) + }) + + it("rolls in place when `rotate` is bumped", async () => { + const stub = makeStub({ + "GET /v2/api_keys/key_abc": () => Effect.succeed(wireApiKey), + "POST /v2/api_keys/key_abc/roll": () => + Effect.succeed({ ...wireApiKey, id: "key_new", secret: "maple_ak_secret2" }), + }) + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* ApiKey.Provider + return yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci", rotate: 2 }, + olds: { name: "ci", rotate: 1 }, + output: { + keyId: "key_abc", + name: "ci", + keyPrefix: "maple_ak_9f2c", + secret: Redacted.make("maple_ak_secret1"), + }, + session, + bindings: [], + }) + }), + ) + expect(attributes.keyId).toBe("key_new") + expect(Redacted.value(attributes.secret)).toBe("maple_ak_secret2") + }) + + it("recreates when the key was revoked out-of-band", async () => { + const stub = makeStub({ + "GET /v2/api_keys/key_abc": () => Effect.succeed({ ...wireApiKey, revoked: true }), + "POST /v2/api_keys": () => Effect.succeed({ ...wireApiKey, id: "key_new", secret: "maple_ak_secret2" }), + }) + const attributes = await runWithProvider( + stub.api, + Effect.gen(function* () { + const provider = yield* ApiKey.Provider + return yield* provider.reconcile({ + id: "ci", + instanceId: "i-1", + news: { name: "ci" }, + olds: { name: "ci" }, + output: { + keyId: "key_abc", + name: "ci", + keyPrefix: "maple_ak_9f2c", + secret: Redacted.make("maple_ak_secret1"), + }, + session, + bindings: [], + }) + }), + ) + expect(attributes.keyId).toBe("key_new") + }) +}) diff --git a/lib/alchemy-maple/tsconfig.json b/lib/alchemy-maple/tsconfig.json new file mode 100644 index 000000000..dec1248aa --- /dev/null +++ b/lib/alchemy-maple/tsconfig.json @@ -0,0 +1,21 @@ +{ + "include": ["src/**/*.ts", "test/**/*.ts"], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "verbatimModuleSyntax": true, + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "plugins": [ + { + "name": "@effect/language-service", + "reportSuggestionsAsWarningsInTsc": true + } + ] + } +} diff --git a/lib/alchemy-maple/tsdown.config.ts b/lib/alchemy-maple/tsdown.config.ts new file mode 100644 index 000000000..9cf0211ec --- /dev/null +++ b/lib/alchemy-maple/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown" + +export default defineConfig({ + entry: { + index: "./src/index.ts", + }, + format: "esm", + dts: true, + outDir: "dist", + deps: { + neverBundle: ["effect", "alchemy"], + }, +}) diff --git a/packages/db/scripts/grant-runtime-role.ts b/packages/db/scripts/grant-runtime-role.ts index 094072615..afeb6e5f3 100644 --- a/packages/db/scripts/grant-runtime-role.ts +++ b/packages/db/scripts/grant-runtime-role.ts @@ -15,9 +15,13 @@ * * Two connection modes: * 1. DATABASE_URL set → connect directly (stg / PR-preview CI, where the - * migrate step already has MAPLE_PG_URL). Runtime role defaults to that - * URL's username (the managed Hyperdrive is built from the same URL, so - * this is a harmless self-grant) unless MAPLE_PG_RUNTIME_ROLE overrides. + * migrate step already has MAPLE_PG_URL). Runtime role defaults to the + * session's `current_user` (the managed Hyperdrive is built from the same + * URL, so this is a harmless self-grant) unless MAPLE_PG_RUNTIME_ROLE + * overrides. It must come from the session and NOT from the URL's + * username: PlanetScale connection strings carry a routing suffix + * (`.`) that is stripped before the server sees it, so + * granting to the URL username fails with `role … does not exist`. * 2. branch arg only → broker an ephemeral postgres-inheriting credential * via `withBranchConnection` (prod `main`, applied out of band). The * brokered URL's user is the migration role, NOT the runtime role, so @@ -76,9 +80,23 @@ const grantStatements = (role: string): readonly string[] => { * Open a short-lived connection on `connectionUrl` and apply the grant pass for * `role`. Idempotent — safe to re-run on every deploy. */ -export const applyRuntimeGrants = async (connectionUrl: string, role: string): Promise => { +const sessionRole = async (sql: postgres.Sql): Promise => { + const [row] = await sql`SELECT current_user` + const role: unknown = row?.current_user + if (typeof role !== "string" || role.length === 0) { + return fail("`SELECT current_user` returned no role — cannot determine who to grant to") + } + return role +} + +export const applyRuntimeGrants = async (connectionUrl: string, explicitRole?: string): Promise => { const sql = postgres(connectionUrl, { max: 1, fetch_types: false }) try { + // No explicit role → self-grant to whoever this connection authenticated + // as. Asking the server (rather than parsing the URL) is what makes this + // work on PlanetScale, whose usernames carry a routing suffix. + const role = explicitRole ?? (await sessionRole(sql)) + console.log(`→ Granting runtime privileges to "${role}"\n`) for (const statement of grantStatements(role)) { console.log(` → ${statement}`) await sql.unsafe(statement) @@ -89,10 +107,9 @@ export const applyRuntimeGrants = async (connectionUrl: string, role: string): P } } -const resolveRole = (fallback?: string): string => { +const requireRole = (): string => { const explicit = process.env.MAPLE_PG_RUNTIME_ROLE?.trim() if (explicit) return explicit - if (fallback) return fallback return fail( "MAPLE_PG_RUNTIME_ROLE is not set — cannot determine the runtime app role to grant. " + "Set it to the role embedded in the prod Hyperdrive (maple-db) connection.", @@ -103,27 +120,19 @@ const resolveRole = (fallback?: string): string => { if (import.meta.main) { const directUrl = process.env.DATABASE_URL?.trim() if (directUrl) { - // Mode 1: direct connection. Default the role to the URL's user — the - // managed Hyperdrive (stg / PR preview) is built from this same URL, so - // granting to that user is a self-grant no-op that future-proofs against - // the runtime/migrate roles ever diverging. - const fallback = (() => { - try { - return decodeURIComponent(new URL(directUrl).username) || undefined - } catch { - return undefined - } - })() - const role = resolveRole(fallback) - console.log(`→ Granting runtime privileges to "${role}" via DATABASE_URL\n`) - await applyRuntimeGrants(directUrl, role) + // Mode 1: direct connection. Without an override the grant targets the + // session's own role — the managed Hyperdrive (stg / PR preview) is built + // from this same URL, so that self-grant is a no-op which future-proofs + // against the runtime/migrate roles ever diverging. + console.log("→ Granting runtime privileges via DATABASE_URL") + await applyRuntimeGrants(directUrl, process.env.MAPLE_PG_RUNTIME_ROLE?.trim() || undefined) } else { // Mode 2: broker an ephemeral credential for the branch (prod path). const branch = process.argv[2]?.trim() if (!branch) { fail("Usage: DATABASE_URL=… grant-runtime-role.ts OR grant-runtime-role.ts ") } - const role = resolveRole() + const role = requireRole() await withBranchConnection(branch as string, async (connectionUrl) => { console.log(`→ Granting runtime privileges to "${role}" on ${resolveDatabase()}/${branch}\n`) await applyRuntimeGrants(connectionUrl, role) diff --git a/packages/domain/src/http/v2/api.ts b/packages/domain/src/http/v2/api.ts index 7c3c7b71e..b09a27862 100644 --- a/packages/domain/src/http/v2/api.ts +++ b/packages/domain/src/http/v2/api.ts @@ -16,7 +16,6 @@ import { V2SessionReplaysApiGroup } from "./session-replays" import { V2LogsApiGroup, V2MetricsApiGroup, - V2QueryApiGroup, V2ServiceMapApiGroup, V2ServicesApiGroup, V2TracesApiGroup, @@ -79,7 +78,6 @@ export class MapleApiV2 extends HttpApi.make("MapleApiV2") .add(V2MetricsApiGroup) .add(V2ServicesApiGroup) .add(V2ServiceMapApiGroup) - .add(V2QueryApiGroup) .middleware(V2UnexpectedErrors) .annotateMerge( OpenApi.annotations({ diff --git a/packages/domain/src/http/v2/auth.ts b/packages/domain/src/http/v2/auth.ts index dc0d617ac..3e2d44518 100644 --- a/packages/domain/src/http/v2/auth.ts +++ b/packages/domain/src/http/v2/auth.ts @@ -79,9 +79,13 @@ const READ_ONLY_POST_PATHS = new Set([ "/v2/session_replays/for_trace", "/v2/alerts/rules/preview", "/v2/traces/search", + "/v2/traces/timeseries", + "/v2/traces/breakdown", "/v2/logs/search", + "/v2/logs/timeseries", + "/v2/logs/breakdown", "/v2/metrics/timeseries", - "/v2/query", + "/v2/metrics/breakdown", ]) /** diff --git a/packages/domain/src/http/v2/openapi.test.ts b/packages/domain/src/http/v2/openapi.test.ts index 05710cd8b..e5d420977 100644 --- a/packages/domain/src/http/v2/openapi.test.ts +++ b/packages/domain/src/http/v2/openapi.test.ts @@ -128,14 +128,18 @@ describe("MapleApiV2 OpenAPI", () => { "POST /v2/instrumentation/recommendations/{id}/reopen", "POST /v2/investigations", "POST /v2/investigations/{id}/status", + "POST /v2/logs/breakdown", "POST /v2/logs/search", + "POST /v2/logs/timeseries", + "POST /v2/metrics/breakdown", "POST /v2/metrics/timeseries", - "POST /v2/query", "POST /v2/scrape_targets", "POST /v2/scrape_targets/{id}/probe", "POST /v2/session_replays/for_trace", "POST /v2/session_replays/search", + "POST /v2/traces/breakdown", "POST /v2/traces/search", + "POST /v2/traces/timeseries", "PUT /v2/anomalies/incidents/{id}/issue", ]) }) @@ -215,6 +219,25 @@ describe("MapleApiV2 OpenAPI", () => { expect(names.some((n) => n.startsWith("V2") || n.includes("@maple") || n.includes("/"))).toBe(false) }) + it("freezes the signal-scoped telemetry operations, examples, and result schemas", () => { + expect(spec.paths?.["/v2/query"]).toBeUndefined() + for (const [path, operationId, resultSchema] of [ + ["/v2/traces/timeseries", "queryTraceTimeseries", "TraceTimeseriesResult"], + ["/v2/traces/breakdown", "queryTraceBreakdown", "TraceBreakdownResult"], + ["/v2/logs/timeseries", "queryLogTimeseries", "LogTimeseriesResult"], + ["/v2/logs/breakdown", "queryLogBreakdown", "LogBreakdownResult"], + ["/v2/metrics/timeseries", "queryMetricsTimeseries", "MetricTimeseriesResult"], + ["/v2/metrics/breakdown", "queryMetricBreakdown", "MetricBreakdownResult"], + ] as const) { + const op = operation("post", path) + expect(op.operationId).toBe(operationId) + expect(op.responses["200"].content["application/json"].schema.$ref).toBe( + `#/components/schemas/${resultSchema}`, + ) + expect(schemas[resultSchema].examples).toHaveLength(1) + } + }) + it("documents the ApiKey schema with a title, description, and a decodable example", () => { const apiKey = schemas["ApiKey"] expect(apiKey.title).toBe("API Key") diff --git a/packages/domain/src/http/v2/telemetry.ts b/packages/domain/src/http/v2/telemetry.ts index 03228ea08..0e1b8d573 100644 --- a/packages/domain/src/http/v2/telemetry.ts +++ b/packages/domain/src/http/v2/telemetry.ts @@ -13,6 +13,7 @@ const PositiveFinite = Schema.Number.check(Schema.isFinite(), Schema.isGreaterTh const NonNegativeFinite = Schema.Number.check(Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0)) const BreakdownLimit = PositiveInteger.check(Schema.isLessThanOrEqualTo(100)) const TimeseriesSeriesLimit = PositiveInteger.check(Schema.isLessThanOrEqualTo(100)) +const NonEmptyString = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed()) export const LogPublicId = PublicId(PublicIdPrefixes.log, Schema.String).annotate({ identifier: "LogId", @@ -40,6 +41,396 @@ export const V2TelemetryWindowQuery = Schema.Struct({ title: "Telemetry time window", }) +export const V2AttributeFilter = Schema.Union([ + Schema.Struct({ + key: NonEmptyString, + operator: Schema.Literal("exists"), + value: Schema.optionalKey(Schema.Never), + negated: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + key: NonEmptyString, + operator: Schema.Literals(["equals", "contains"]), + value: Schema.String, + negated: Schema.optionalKey(Schema.Boolean), + }), + Schema.Struct({ + key: NonEmptyString, + operator: Schema.Literals(["gt", "gte", "lt", "lte"]), + value: Schema.Number.check(Schema.isFinite()), + negated: Schema.optionalKey(Schema.Boolean), + }), +]).annotate({ + identifier: "AttributeFilter", + title: "Attribute filter", + description: + "Matches an attribute by key. `exists` has no value, string operators require a string value, and numeric operators require a finite number.", +}) +export type V2AttributeFilter = Schema.Schema.Type +const AttributeFilterCollection = Schema.Array(V2AttributeFilter).check(Schema.isMaxLength(20)) + +const TraceFilters = Schema.Struct({ + service_name: Schema.optionalKey(ServiceName), + span_name: Schema.optionalKey(NonEmptyString), + status_code: Schema.optionalKey(Schema.Literals(["Ok", "Error", "Unset"])), + has_error: Schema.optionalKey(Schema.Boolean), + min_duration_ms: Schema.optionalKey(NonNegativeFinite), + max_duration_ms: Schema.optionalKey(NonNegativeFinite), + http_method: Schema.optionalKey(NonEmptyString), + http_route: Schema.optionalKey(NonEmptyString), + http_status_code: Schema.optionalKey(NonEmptyString), + deployment_environment: Schema.optionalKey(NonEmptyString), + service_namespace: Schema.optionalKey(NonEmptyString), + span_scope: Schema.optionalKey(Schema.Literal("root")), + attributes: Schema.optionalKey(AttributeFilterCollection), + resource_attributes: Schema.optionalKey(AttributeFilterCollection), +}).annotate({ identifier: "TraceFilters", title: "Trace filters" }) + +const LogFilters = Schema.Struct({ + service_name: Schema.optionalKey(ServiceName), + severity: Schema.optionalKey(NonEmptyString), + minimum_severity: Schema.optionalKey( + Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 255 })), + ), + trace_id: Schema.optionalKey(TraceId), + span_id: Schema.optionalKey(SpanId), + body_search: Schema.optionalKey(NonEmptyString), + deployment_environment: Schema.optionalKey(NonEmptyString), + service_namespace: Schema.optionalKey(NonEmptyString), + attributes: Schema.optionalKey(AttributeFilterCollection), + resource_attributes: Schema.optionalKey(AttributeFilterCollection), +}).annotate({ identifier: "LogFilters", title: "Log filters" }) + +const MetricType = Schema.Literals(["sum", "gauge", "histogram", "exponential_histogram"]) +const MetricFilters = Schema.Struct({ + metric_name: MetricName, + metric_type: MetricType, + service_name: Schema.optionalKey(ServiceName), +}).annotate({ identifier: "MetricFilters", title: "Metric filters" }) + +export const V2TimeseriesValuePoint = Schema.Struct({ + timestamp: Timestamp, + value: Schema.Number, +}).annotate({ identifier: "TimeseriesValuePoint", title: "Timeseries value point" }) +export const V2TimeseriesSeries = Schema.Struct({ + group: Schema.NullOr(Schema.String), + points: Schema.Array(V2TimeseriesValuePoint), +}).annotate({ identifier: "TimeseriesSeries", title: "Timeseries series" }) +export const V2BreakdownItem = Schema.Struct({ + name: Schema.String, + value: Schema.Number, +}).annotate({ identifier: "BreakdownItem", title: "Breakdown item" }) + +const traceAggregations = [ + "count", + "avg_duration", + "p50_duration", + "p95_duration", + "p99_duration", + "error_rate", + "apdex", +] as const +const traceGroupBy = ["service", "span_name", "status_code", "http_method", "attribute"] as const +const metricTimeseriesAggregations = ["avg", "sum", "min", "max", "count", "rate", "increase"] as const +const metricBreakdownAggregations = ["avg", "sum", "count"] as const + +const V2TraceTimeseriesParamsBase = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literals(traceAggregations), + filters: Schema.optionalKey(TraceFilters), + group_by: Schema.optionalKey(Schema.Literals(traceGroupBy)), + group_by_attribute_key: Schema.optionalKey(NonEmptyString), + bucket_seconds: Schema.optionalKey(PositiveInteger), + series_limit: Schema.optionalKey(TimeseriesSeriesLimit), + apdex_threshold_ms: Schema.optionalKey(PositiveFinite), +}) +export const V2TraceTimeseriesParams = V2TraceTimeseriesParamsBase.check( + Schema.makeFilter((value) => value.group_by !== "attribute" || !!value.group_by_attribute_key, { + message: "group_by=attribute requires group_by_attribute_key", + }), +).annotate({ + identifier: "TraceTimeseriesParams", + title: "Trace timeseries parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "p95_duration", + filters: { service_name: "api" }, + group_by: "span_name", + bucket_seconds: 60, + series_limit: 50, + }), + ], +}) + +const V2TraceBreakdownParamsBase = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literals(traceAggregations), + filters: Schema.optionalKey(TraceFilters), + group_by: Schema.Literals(traceGroupBy), + group_by_attribute_key: Schema.optionalKey(NonEmptyString), + limit: Schema.optionalKey(BreakdownLimit), + apdex_threshold_ms: Schema.optionalKey(PositiveFinite), +}) +export const V2TraceBreakdownParams = V2TraceBreakdownParamsBase.check( + Schema.makeFilter((value) => value.group_by !== "attribute" || !!value.group_by_attribute_key, { + message: "group_by=attribute requires group_by_attribute_key", + }), +).annotate({ + identifier: "TraceBreakdownParams", + title: "Trace breakdown parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "error_rate", + group_by: "service", + limit: 20, + }), + ], +}) + +export const V2LogTimeseriesParams = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literal("count"), + filters: Schema.optionalKey(LogFilters), + group_by: Schema.optionalKey(Schema.Literals(["service", "severity"])), + bucket_seconds: Schema.optionalKey(PositiveInteger), + series_limit: Schema.optionalKey(TimeseriesSeriesLimit), +}).annotate({ + identifier: "LogTimeseriesParams", + title: "Log timeseries parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "count", + filters: { minimum_severity: 17 }, + group_by: "severity", + }), + ], +}) + +export const V2LogBreakdownParams = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literal("count"), + filters: Schema.optionalKey(LogFilters), + group_by: Schema.Literals(["service", "severity"]), + limit: Schema.optionalKey(BreakdownLimit), +}).annotate({ + identifier: "LogBreakdownParams", + title: "Log breakdown parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "count", + group_by: "service", + limit: 20, + }), + ], +}) + +const V2MetricsTimeseriesParamsBase = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literals(metricTimeseriesAggregations), + filters: MetricFilters, + group_by: Schema.optionalKey(Schema.Literals(["service", "attribute", "resource_attribute"])), + group_by_attribute_key: Schema.optionalKey(NonEmptyString), + group_by_resource_attribute_key: Schema.optionalKey(NonEmptyString), + bucket_seconds: Schema.optionalKey(PositiveInteger), + series_limit: Schema.optionalKey(TimeseriesSeriesLimit), +}) +export const V2MetricsTimeseriesParams = V2MetricsTimeseriesParamsBase.check( + Schema.makeFilter( + (value) => + (value.group_by !== "attribute" || !!value.group_by_attribute_key) && + (value.group_by !== "resource_attribute" || !!value.group_by_resource_attribute_key) && + ((value.aggregation !== "rate" && value.aggregation !== "increase") || + value.filters.metric_type === "sum"), + { + message: + "Attribute grouping requires its attribute key, and rate/increase require filters.metric_type to be sum", + }, + ), +).annotate({ + identifier: "MetricsTimeseriesParams", + title: "Metrics timeseries parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "avg", + filters: { metric_name: "http.server.duration", metric_type: "histogram" }, + group_by: "service", + }), + ], +}) + +const V2MetricsBreakdownParamsBase = Schema.Struct({ + ...RequiredTimeRange, + aggregation: Schema.Literals(metricBreakdownAggregations), + filters: MetricFilters, + group_by: Schema.Literals(["service", "attribute", "resource_attribute"]), + group_by_attribute_key: Schema.optionalKey(NonEmptyString), + group_by_resource_attribute_key: Schema.optionalKey(NonEmptyString), + limit: Schema.optionalKey(BreakdownLimit), +}) +export const V2MetricsBreakdownParams = V2MetricsBreakdownParamsBase.check( + Schema.makeFilter( + (value) => + (value.group_by !== "attribute" || !!value.group_by_attribute_key) && + (value.group_by !== "resource_attribute" || !!value.group_by_resource_attribute_key), + { message: "Attribute grouping requires the corresponding attribute key" }, + ), +).annotate({ + identifier: "MetricsBreakdownParams", + title: "Metrics breakdown parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + aggregation: "sum", + filters: { metric_name: "http.server.request.size", metric_type: "histogram" }, + group_by: "service", + }), + ], +}) + +const timeseriesResultFields = { + start_time: Timestamp, + end_time: Timestamp, + bucket_seconds: PositiveInteger, + group_by: Schema.NullOr(Schema.String), + series: Schema.Array(V2TimeseriesSeries), +} as const +const breakdownResultFields = { + start_time: Timestamp, + end_time: Timestamp, + group_by: Schema.String, + data: Schema.Array(V2BreakdownItem), +} as const + +export const V2TraceTimeseriesResult = Schema.Struct({ + object: Schema.Literal("trace_timeseries"), + aggregation: Schema.Literals(traceAggregations), + ...timeseriesResultFields, +}).annotate({ + identifier: "TraceTimeseriesResult", + title: "Trace timeseries result", + examples: [ + wireExample({ + object: "trace_timeseries", + aggregation: "count", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + bucket_seconds: 60, + group_by: "service", + series: [{ group: "api", points: [{ timestamp: "2026-07-15T12:00:00.000Z", value: 42 }] }], + }), + ], +}) +export const V2TraceBreakdownResult = Schema.Struct({ + object: Schema.Literal("trace_breakdown"), + aggregation: Schema.Literals(traceAggregations), + ...breakdownResultFields, +}).annotate({ + identifier: "TraceBreakdownResult", + title: "Trace breakdown result", + examples: [ + wireExample({ + object: "trace_breakdown", + aggregation: "error_rate", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + group_by: "service", + data: [{ name: "api", value: 0.05 }], + }), + ], +}) +export const V2LogTimeseriesResult = Schema.Struct({ + object: Schema.Literal("log_timeseries"), + aggregation: Schema.Literal("count"), + ...timeseriesResultFields, +}).annotate({ + identifier: "LogTimeseriesResult", + title: "Log timeseries result", + examples: [ + wireExample({ + object: "log_timeseries", + aggregation: "count", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + bucket_seconds: 60, + group_by: null, + series: [{ group: null, points: [{ timestamp: "2026-07-15T12:00:00.000Z", value: 10 }] }], + }), + ], +}) +export const V2LogBreakdownResult = Schema.Struct({ + object: Schema.Literal("log_breakdown"), + aggregation: Schema.Literal("count"), + ...breakdownResultFields, +}).annotate({ + identifier: "LogBreakdownResult", + title: "Log breakdown result", + examples: [ + wireExample({ + object: "log_breakdown", + aggregation: "count", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + group_by: "severity", + data: [{ name: "ERROR", value: 10 }], + }), + ], +}) +export const V2MetricTimeseriesResult = Schema.Struct({ + object: Schema.Literal("metric_timeseries"), + aggregation: Schema.Literals(metricTimeseriesAggregations), + ...timeseriesResultFields, +}).annotate({ + identifier: "MetricTimeseriesResult", + title: "Metric timeseries result", + examples: [ + wireExample({ + object: "metric_timeseries", + aggregation: "avg", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + bucket_seconds: 60, + group_by: "service", + series: [{ group: "api", points: [{ timestamp: "2026-07-15T12:00:00.000Z", value: 42 }] }], + }), + ], +}) +export const V2MetricBreakdownResult = Schema.Struct({ + object: Schema.Literal("metric_breakdown"), + aggregation: Schema.Literals(metricBreakdownAggregations), + ...breakdownResultFields, +}).annotate({ + identifier: "MetricBreakdownResult", + title: "Metric breakdown result", + examples: [ + wireExample({ + object: "metric_breakdown", + aggregation: "sum", + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + group_by: "service", + data: [{ name: "api", value: 420 }], + }), + ], +}) + +export type V2TraceTimeseriesResult = Schema.Schema.Type +export type V2TraceBreakdownResult = Schema.Schema.Type +export type V2LogTimeseriesResult = Schema.Schema.Type +export type V2LogBreakdownResult = Schema.Schema.Type +export type V2MetricTimeseriesResult = Schema.Schema.Type +export type V2MetricBreakdownResult = Schema.Schema.Type + export const V2TraceSummary = Schema.Struct({ id: TraceId, object: Schema.Literal("trace"), @@ -48,8 +439,8 @@ export const V2TraceSummary = Schema.Struct({ root_span_name: Schema.String, root_span_kind: Schema.String, root_service_name: Schema.String, - status_code: Schema.String, - has_error: Schema.Boolean, + root_status_code: Schema.String, + root_has_error: Schema.Boolean, deployment_environment: Schema.NullOr(Schema.String), service_namespace: Schema.NullOr(Schema.String), http_method: Schema.NullOr(Schema.String), @@ -68,8 +459,8 @@ export const V2TraceSummary = Schema.Struct({ root_span_name: "GET /checkout", root_span_kind: "Server", root_service_name: "api", - status_code: "Ok", - has_error: false, + root_status_code: "Ok", + root_has_error: false, deployment_environment: "production", service_namespace: "checkout", http_method: "GET", @@ -121,18 +512,22 @@ export type V2Trace = Schema.Schema.Type export const V2TraceSearchParams = Schema.Struct({ ...RequiredTimeRange, ...PostPagination, - service_name: Schema.optionalKey(ServiceName), - span_name: Schema.optionalKey(Schema.String), - has_error: Schema.optionalKey(Schema.Boolean), - min_duration_ms: Schema.optionalKey(NonNegativeFinite), - max_duration_ms: Schema.optionalKey(NonNegativeFinite), - http_method: Schema.optionalKey(Schema.String), - http_status_code: Schema.optionalKey(Schema.String), - deployment_environment: Schema.optionalKey(Schema.String), - service_namespace: Schema.optionalKey(Schema.String), + filters: Schema.optionalKey(TraceFilters), }).annotate({ identifier: "TraceSearchParams", title: "Trace search parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + filters: { + service_name: "api", + has_error: true, + attributes: [{ key: "http.route", operator: "contains", value: "/checkout" }], + }, + limit: 20, + }), + ], }) const TraceList = ListOf(V2TraceSummary).annotate({ @@ -154,6 +549,32 @@ export class V2TracesApiGroup extends HttpApiGroup.make("traces") }), ), ) + .add( + HttpApiEndpoint.post("timeseries", "/timeseries", { + payload: V2TraceTimeseriesParams, + success: V2TraceTimeseriesResult, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "queryTraceTimeseries", + summary: "Query trace timeseries", + description: "Aggregates traces into chronological series. Requires `traces:read`.", + }), + ), + ) + .add( + HttpApiEndpoint.post("breakdown", "/breakdown", { + payload: V2TraceBreakdownParams, + success: V2TraceBreakdownResult, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "queryTraceBreakdown", + summary: "Query trace breakdown", + description: "Aggregates traces by one dimension. Requires `traces:read`.", + }), + ), + ) .add( HttpApiEndpoint.get("retrieve", "/:trace_id", { params: { trace_id: TraceId }, @@ -213,17 +634,19 @@ export type V2Log = Schema.Schema.Type export const V2LogSearchParams = Schema.Struct({ ...RequiredTimeRange, ...PostPagination, - service_name: Schema.optionalKey(ServiceName), - severity: Schema.optionalKey(Schema.String), - min_severity: Schema.optionalKey( - Schema.Number.check(Schema.isInt(), Schema.isBetween({ minimum: 0, maximum: 255 })), - ), - trace_id: Schema.optionalKey(TraceId), - span_id: Schema.optionalKey(SpanId), - search: Schema.optionalKey(Schema.String), - deployment_environment: Schema.optionalKey(Schema.String), - service_namespace: Schema.optionalKey(Schema.String), -}).annotate({ identifier: "LogSearchParams", title: "Log search parameters" }) + filters: Schema.optionalKey(LogFilters), +}).annotate({ + identifier: "LogSearchParams", + title: "Log search parameters", + examples: [ + wireExample({ + start_time: "2026-07-15T12:00:00.000Z", + end_time: "2026-07-15T13:00:00.000Z", + filters: { service_name: "api", minimum_severity: 17, body_search: "checkout" }, + limit: 20, + }), + ], +}) const LogList = ListOf(V2Log).annotate({ identifier: "LogList", title: "Log list", @@ -243,6 +666,32 @@ export class V2LogsApiGroup extends HttpApiGroup.make("logs") }), ), ) + .add( + HttpApiEndpoint.post("timeseries", "/timeseries", { + payload: V2LogTimeseriesParams, + success: V2LogTimeseriesResult, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "queryLogTimeseries", + summary: "Query log timeseries", + description: "Counts logs in chronological buckets. Requires `logs:read`.", + }), + ), + ) + .add( + HttpApiEndpoint.post("breakdown", "/breakdown", { + payload: V2LogBreakdownParams, + success: V2LogBreakdownResult, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "queryLogBreakdown", + summary: "Query log breakdown", + description: "Counts logs by service or severity. Requires `logs:read`.", + }), + ), + ) .add( HttpApiEndpoint.get("retrieve", "/:id", { params: { id: LogPublicId }, @@ -292,59 +741,6 @@ export const V2MetricListQuery = Schema.Struct({ search: Schema.optional(Schema.String), }).annotate({ identifier: "MetricListQuery", title: "Metric list query" }) -export const V2AttributeFilter = Schema.Struct({ - key: Schema.String, - value: Schema.optionalKey(Schema.String), - mode: Schema.Literals(["equals", "exists", "gt", "gte", "lt", "lte", "contains"]), - negated: Schema.optionalKey(Schema.Boolean), -}).annotate({ identifier: "AttributeFilter", title: "Attribute filter" }) - -const MetricsMetric = Schema.Literals(["avg", "sum", "min", "max", "count", "rate", "increase"]) -const MetricType = Schema.Literals(["sum", "gauge", "histogram", "exponential_histogram"]) -const MetricsFilters = { - metric_name: MetricName, - metric_type: MetricType, - service_name: Schema.optionalKey(ServiceName), - group_by_attribute_key: Schema.optionalKey(Schema.String), - group_by_resource_attribute_key: Schema.optionalKey(Schema.String), - attribute_filters: Schema.optionalKey(Schema.Array(V2AttributeFilter)), - resource_attribute_filters: Schema.optionalKey(Schema.Array(V2AttributeFilter)), -} as const - -export const V2MetricsTimeseriesParams = Schema.Struct({ - ...RequiredTimeRange, - metric: MetricsMetric, - ...MetricsFilters, - group_by: Schema.optionalKey( - Schema.Array(Schema.Literals(["service", "attribute", "resource_attribute", "none"])), - ), - bucket_seconds: Schema.optionalKey(Schema.Number.check(Schema.isInt(), Schema.isGreaterThan(0))), - series_limit: Schema.optionalKey(TimeseriesSeriesLimit), -}).annotate({ - identifier: "MetricsTimeseriesParams", - title: "Metrics timeseries parameters", -}) - -export const V2TimeseriesPoint = Schema.Struct({ - bucket: Timestamp, - series: Schema.Record(Schema.String, Schema.Number), -}).annotate({ identifier: "TimeseriesPoint", title: "Timeseries point" }) -export const V2BreakdownItem = Schema.Struct({ - name: Schema.String, - value: Schema.Number, -}).annotate({ identifier: "BreakdownItem", title: "Breakdown item" }) -export const V2StructuredQueryResult = Schema.Struct({ - object: Schema.Literal("query_result"), - kind: Schema.Literals(["timeseries", "breakdown"]), - source: Schema.Literals(["traces", "logs", "metrics"]), - timeseries: Schema.Array(V2TimeseriesPoint), - breakdown: Schema.Array(V2BreakdownItem), -}).annotate({ - identifier: "StructuredQueryResult", - title: "Structured query result", -}) -export type V2StructuredQueryResult = Schema.Schema.Type - const MetricList = ListOf(V2Metric).annotate({ identifier: "MetricList", title: "Metric list", @@ -367,7 +763,7 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") .add( HttpApiEndpoint.post("timeseries", "/timeseries", { payload: V2MetricsTimeseriesParams, - success: V2StructuredQueryResult, + success: V2MetricTimeseriesResult, error: [...commonErrors], }).annotateMerge( OpenApi.annotations({ @@ -377,6 +773,19 @@ export class V2MetricsApiGroup extends HttpApiGroup.make("metrics") }), ), ) + .add( + HttpApiEndpoint.post("breakdown", "/breakdown", { + payload: V2MetricsBreakdownParams, + success: V2MetricBreakdownResult, + error: [...commonErrors], + }).annotateMerge( + OpenApi.annotations({ + identifier: "queryMetricBreakdown", + summary: "Query metric breakdown", + description: "Aggregates one metric by a single dimension. Requires `metrics:read`.", + }), + ), + ) .prefix("/v2/metrics") .middleware(AuthorizationV2) .middleware(V2SchemaErrors) @@ -511,131 +920,3 @@ export class V2ServiceMapApiGroup extends HttpApiGroup.make("serviceMap") description: "Service-to-service topology.", }), ) {} - -const TracesMetric = Schema.Literals([ - "count", - "avg_duration", - "p50_duration", - "p95_duration", - "p99_duration", - "error_rate", - "apdex", -]) -const TraceFilters = Schema.Struct({ - service_name: Schema.optionalKey(ServiceName), - span_name: Schema.optionalKey(Schema.String), - root_spans_only: Schema.optionalKey(Schema.Boolean), - errors_only: Schema.optionalKey(Schema.Boolean), - environments: Schema.optionalKey(Schema.Array(Schema.String)), - namespaces: Schema.optionalKey(Schema.Array(Schema.String)), - min_duration_ms: Schema.optionalKey(NonNegativeFinite), - max_duration_ms: Schema.optionalKey(NonNegativeFinite), - group_by_attribute_keys: Schema.optionalKey(Schema.Array(Schema.String)), - attribute_filters: Schema.optionalKey(Schema.Array(V2AttributeFilter)), - resource_attribute_filters: Schema.optionalKey(Schema.Array(V2AttributeFilter)), -}) -const LogFilters = Schema.Struct({ - service_name: Schema.optionalKey(ServiceName), - severity: Schema.optionalKey(Schema.String), - trace_id: Schema.optionalKey(TraceId), - search: Schema.optionalKey(Schema.String), - environments: Schema.optionalKey(Schema.Array(Schema.String)), - namespaces: Schema.optionalKey(Schema.Array(Schema.String)), -}) -const MetricsFiltersSchema = Schema.Struct(MetricsFilters) - -const V2TracesTimeseriesSpec = Schema.Struct({ - kind: Schema.Literal("timeseries"), - source: Schema.Literal("traces"), - metric: TracesMetric, - group_by: Schema.optionalKey( - Schema.Array( - Schema.Literals(["service", "span_name", "status_code", "http_method", "attribute", "none"]), - ), - ), - filters: Schema.optionalKey(TraceFilters), - bucket_seconds: Schema.optionalKey(PositiveInteger), - series_limit: Schema.optionalKey(TimeseriesSeriesLimit), - apdex_threshold_ms: Schema.optionalKey(PositiveFinite), -}) -const V2LogsTimeseriesSpec = Schema.Struct({ - kind: Schema.Literal("timeseries"), - source: Schema.Literal("logs"), - metric: Schema.Literal("count"), - group_by: Schema.optionalKey(Schema.Array(Schema.Literals(["service", "severity", "none"]))), - filters: Schema.optionalKey(LogFilters), - bucket_seconds: Schema.optionalKey(PositiveInteger), - series_limit: Schema.optionalKey(TimeseriesSeriesLimit), -}) -const V2MetricsTimeseriesSpec = Schema.Struct({ - kind: Schema.Literal("timeseries"), - source: Schema.Literal("metrics"), - metric: MetricsMetric, - group_by: Schema.optionalKey( - Schema.Array(Schema.Literals(["service", "attribute", "resource_attribute", "none"])), - ), - filters: MetricsFiltersSchema, - bucket_seconds: Schema.optionalKey(PositiveInteger), - series_limit: Schema.optionalKey(TimeseriesSeriesLimit), -}) -const V2TracesBreakdownSpec = Schema.Struct({ - kind: Schema.Literal("breakdown"), - source: Schema.Literal("traces"), - metric: TracesMetric, - group_by: Schema.Literals(["service", "span_name", "status_code", "http_method", "attribute"]), - filters: Schema.optionalKey(TraceFilters), - limit: Schema.optionalKey(BreakdownLimit), - apdex_threshold_ms: Schema.optionalKey(PositiveFinite), -}) -const V2LogsBreakdownSpec = Schema.Struct({ - kind: Schema.Literal("breakdown"), - source: Schema.Literal("logs"), - metric: Schema.Literal("count"), - group_by: Schema.Literals(["service", "severity"]), - filters: Schema.optionalKey(LogFilters), - limit: Schema.optionalKey(BreakdownLimit), -}) -const V2MetricsBreakdownSpec = Schema.Struct({ - kind: Schema.Literal("breakdown"), - source: Schema.Literal("metrics"), - metric: Schema.Literals(["avg", "sum", "count"]), - group_by: Schema.Literals(["service", "attribute", "resource_attribute"]), - filters: MetricsFiltersSchema, - limit: Schema.optionalKey(BreakdownLimit), -}) -export const V2QuerySpec = Schema.Union([ - V2TracesTimeseriesSpec, - V2LogsTimeseriesSpec, - V2MetricsTimeseriesSpec, - V2TracesBreakdownSpec, - V2LogsBreakdownSpec, - V2MetricsBreakdownSpec, -]).annotate({ identifier: "QuerySpec", title: "Query specification" }) -export type V2QuerySpec = Schema.Schema.Type -export const V2QueryParams = Schema.Struct({ - ...RequiredTimeRange, - query: V2QuerySpec, -}).annotate({ identifier: "QueryParams", title: "Query parameters" }) -export class V2QueryApiGroup extends HttpApiGroup.make("query") - .add( - HttpApiEndpoint.post("execute", "/", { - payload: V2QueryParams, - success: V2StructuredQueryResult, - error: [...commonErrors], - }).annotateMerge( - OpenApi.annotations({ - identifier: "executeQuery", - summary: "Execute a telemetry query", - description: "Executes a typed telemetry query. Requires `query:read`.", - }), - ), - ) - .prefix("/v2/query") - .middleware(AuthorizationV2) - .middleware(V2SchemaErrors) - .annotateMerge( - OpenApi.annotations({ - title: "Query", - description: "Structured telemetry query execution.", - }), - ) {} diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index ee5d8bba0..7becb7f69 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -18,7 +18,12 @@ import { } from "./envelopes" import { notFound, permissionError, rateLimited, V2NotFoundError, V2RateLimitError } from "./errors" import { encodePublicId } from "./public-id" -import { LogPublicId, V2QueryParams } from "./telemetry" +import { + LogPublicId, + V2AttributeFilter, + V2MetricsTimeseriesParams, + V2TraceTimeseriesParams, +} from "./telemetry" const UUID = "0f8fad5b-d9cb-469f-a165-70867728950e" @@ -442,9 +447,13 @@ describe("scopes", () => { }) for (const [path, family] of [ ["/v2/traces/search", "traces"], + ["/v2/traces/timeseries", "traces"], + ["/v2/traces/breakdown", "traces"], ["/v2/logs/search", "logs"], + ["/v2/logs/timeseries", "logs"], + ["/v2/logs/breakdown", "logs"], ["/v2/metrics/timeseries", "metrics"], - ["/v2/query", "query"], + ["/v2/metrics/breakdown", "metrics"], ] as const) { expect(requiredScopeForRequest("POST", path)).toEqual({ family, access: "read" }) } @@ -486,43 +495,62 @@ describe("telemetry contracts", () => { expect(Schema.decodeSync(LogPublicId)(wire)).toBe(internal) }) - it("decodes structured queries with trace attribute grouping", () => { - const structured = Schema.decodeUnknownSync(V2QueryParams)({ + it("decodes signal-scoped trace timeseries with attribute grouping", () => { + const request = Schema.decodeUnknownSync(V2TraceTimeseriesParams)({ start_time: "2026-07-15T00:00:00.000Z", end_time: "2026-07-15T01:00:00.000Z", - query: { - kind: "timeseries", - source: "traces", - metric: "count", - group_by: ["attribute"], - filters: { group_by_attribute_keys: ["http.route"] }, - }, + aggregation: "count", + group_by: "attribute", + group_by_attribute_key: "http.route", }) - expect(structured.query.kind).toBe("timeseries") + expect(request.group_by).toBe("attribute") }) - it("rejects raw SQL and invalid query budgets at the HTTP boundary", () => { + it("rejects invalid filters, grouping, metric compatibility, and budgets", () => { const base = { start_time: "2026-07-15T00:00:00.000Z", end_time: "2026-07-15T01:00:00.000Z", } expect(() => - Schema.decodeUnknownSync(V2QueryParams)({ + Schema.decodeUnknownSync(V2TraceTimeseriesParams)({ ...base, - query: { kind: "raw_sql", sql: "SELECT 1 WHERE $__orgFilter" }, + aggregation: "count", + group_by: "attribute", }), ).toThrow() expect(() => - Schema.decodeUnknownSync(V2QueryParams)({ + Schema.decodeUnknownSync(V2TraceTimeseriesParams)({ ...base, - query: { - kind: "timeseries", - source: "logs", - metric: "count", - bucket_seconds: 0, + aggregation: "count", + bucket_seconds: 0, + }), + ).toThrow() + expect(() => + Schema.decodeUnknownSync(V2MetricsTimeseriesParams)({ + ...base, + aggregation: "rate", + filters: { metric_name: "requests", metric_type: "gauge" }, + }), + ).toThrow() + expect(() => + Schema.decodeUnknownSync(V2TraceTimeseriesParams)({ + ...base, + aggregation: "count", + filters: { + attributes: Array.from({ length: 21 }, (_, index) => ({ + key: `key.${index}`, + operator: "exists", + })), }, }), ).toThrow() + expect(() => + Schema.decodeUnknownSync(V2AttributeFilter)({ + key: "http.status_code", + operator: "exists", + value: "500", + }), + ).toThrow() }) }) diff --git a/packages/domain/src/query-engine.ts b/packages/domain/src/query-engine.ts index 72df0d5f0..eebc7dd5c 100644 --- a/packages/domain/src/query-engine.ts +++ b/packages/domain/src/query-engine.ts @@ -55,6 +55,7 @@ export type TracesMatchModes = Schema.Schema.Type export const TracesFilters = Schema.Struct({ serviceName: Schema.optional(ServiceName), spanName: Schema.optional(SpanName), + statusCode: Schema.optional(Schema.Literals(["Ok", "Error", "Unset"])), rootSpansOnly: Schema.optional(Schema.Boolean), environments: Schema.optional(Schema.Array(DeploymentEnvironment)), namespaces: Schema.optional(Schema.Array(ServiceNamespace)), @@ -76,12 +77,16 @@ export type TracesFilters = Schema.Schema.Type export const LogsFilters = Schema.Struct({ serviceName: Schema.optional(ServiceName), severity: Schema.optional(Schema.String), + minSeverity: Schema.optional(Schema.Number), traceId: Schema.optional(TraceId), + spanId: Schema.optional(Schema.String), search: Schema.optional(Schema.String), environments: Schema.optional(Schema.Array(DeploymentEnvironment)), deploymentEnvMatchMode: Schema.optional(Schema.Literal("contains")), namespaces: Schema.optional(Schema.Array(ServiceNamespace)), namespaceMatchMode: Schema.optional(Schema.Literal("contains")), + attributeFilters: Schema.optional(Schema.Array(AttributeFilter)), + resourceAttributeFilters: Schema.optional(Schema.Array(AttributeFilter)), }) export type LogsFilters = Schema.Schema.Type diff --git a/packages/query-engine/src/ch/queries/logs.test.ts b/packages/query-engine/src/ch/queries/logs.test.ts index 9a0ce9efc..897abee19 100644 --- a/packages/query-engine/src/ch/queries/logs.test.ts +++ b/packages/query-engine/src/ch/queries/logs.test.ts @@ -91,6 +91,9 @@ describe("canUseLogsAggregatesHourly", () => { it("rejects when filters need raw columns the MV doesn't carry", () => { expect(canUseLogsAggregatesHourly({ traceId: "abc" }, 3600)).toBe(false) expect(canUseLogsAggregatesHourly({ search: "boom" }, 3600)).toBe(false) + expect( + canUseLogsAggregatesHourly({ attributeFilters: [{ key: "request.id", mode: "exists" }] }, 3600), + ).toBe(false) expect( canUseLogsAggregatesHourly( { environments: ["prod"], matchModes: { deploymentEnv: "contains" } }, @@ -98,6 +101,23 @@ describe("canUseLogsAggregatesHourly", () => { ), ).toBe(false) }) + + it("applies log and resource attribute filters on the raw table with tenant/time pruning", () => { + const { sql } = compileCH( + logsTimeseriesQuery({ + bucketSeconds: 3600, + attributeFilters: [{ key: "request.id", mode: "equals", value: "req_123" }], + resourceAttributeFilters: [{ key: "cloud.region", mode: "exists" }], + }), + baseParams, + ) + expect(sql).toContain("FROM logs") + expect(sql).not.toContain("FROM logs_aggregates_hourly") + expect(sql).toContain("OrgId = 'org_1'") + expect(sql).toContain("TimestampTime >= '2024-01-01 00:00:00'") + expect(sql).toContain("LogAttributes['request.id'] = 'req_123'") + expect(sql).toContain("mapContains(ResourceAttributes, 'cloud.region')") + }) }) describe("logsTimeseriesQuery MV routing", () => { diff --git a/packages/query-engine/src/ch/queries/logs.ts b/packages/query-engine/src/ch/queries/logs.ts index 86c52033d..601a828bb 100644 --- a/packages/query-engine/src/ch/queries/logs.ts +++ b/packages/query-engine/src/ch/queries/logs.ts @@ -13,6 +13,8 @@ import * as T from "@maple-dev/clickhouse-builder/types" import { unionAll, type CHUnionQuery } from "@maple-dev/clickhouse-builder" import { Logs, LogsAggregatesHourly } from "../tables" import { finalizeTimeseries } from "./series-cap" +import type { AttributeFilter } from "../../query-engine" +import { buildAttrFilterCondition } from "../../traces-shared" // --------------------------------------------------------------------------- // Shared options @@ -21,17 +23,29 @@ import { finalizeTimeseries } from "./series-cap" interface LogsQueryOpts { serviceName?: string severity?: string + minSeverity?: number traceId?: string spanId?: string search?: string environments?: readonly string[] namespaces?: readonly string[] + attributeFilters?: readonly AttributeFilter[] + resourceAttributeFilters?: readonly AttributeFilter[] matchModes?: { deploymentEnv?: "contains" serviceNamespace?: "contains" } } +function logAttributeConditions(opts: LogsQueryOpts): CH.Condition[] { + return [ + ...(opts.attributeFilters ?? []).map((filter) => buildAttrFilterCondition(filter, "LogAttributes")), + ...(opts.resourceAttributeFilters ?? []).map((filter) => + buildAttrFilterCondition(filter, "ResourceAttributes"), + ), + ] +} + /** Stable identity for log records that do not carry a native OTel record ID. */ const logRecordIdentity = ($: ColumnAccessor): CH.Expr => { const record = compileFnCall( @@ -104,6 +118,9 @@ function canUseLogsAggregateInterior(opts: LogsQueryOpts): boolean { if (opts.traceId) return false if (opts.spanId) return false if (opts.search) return false + if (opts.minSeverity !== undefined) return false + if (opts.attributeFilters?.length) return false + if (opts.resourceAttributeFilters?.length) return false if (opts.matchModes?.deploymentEnv === "contains") return false if (opts.matchModes?.serviceNamespace === "contains") return false return true @@ -159,7 +176,11 @@ export function canUseLogsAggregatesHourly( return false } if (opts.traceId) return false + if (opts.spanId) return false if (opts.search) return false + if (opts.minSeverity !== undefined) return false + if (opts.attributeFilters?.length) return false + if (opts.resourceAttributeFilters?.length) return false // MV stores DeploymentEnv / ServiceNamespace as top-level columns; the // `contains` substring match is only supported via positionCaseInsensitive on // the raw map column. @@ -237,8 +258,13 @@ export function logsTimeseriesQuery(opts: LogsTimeseriesOpts): CHQuery $.ServiceName.eq(v)), CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)), + opts.minSeverity !== undefined ? $.SeverityNumber.gte(opts.minSeverity) : undefined, + CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)), + CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)), + CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)), environmentCondition($, opts), namespaceCondition($, opts), + ...logAttributeConditions(opts), ]) .groupBy("bucket", "groupName") .orderBy(["bucket", "asc"], ["groupName", "asc"]) @@ -304,8 +330,13 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery $.ServiceName.eq(v)), CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)), + opts.minSeverity !== undefined ? $.SeverityNumber.gte(opts.minSeverity) : undefined, + CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)), + CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)), + CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)), environmentCondition($, opts), namespaceCondition($, opts), + ...logAttributeConditions(opts), ]) .groupBy("name") .orderBy(["count", "desc"]) @@ -325,8 +356,13 @@ export function logsBreakdownQuery(opts: LogsBreakdownOpts): CHQuery $.ServiceName.eq(v)), CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)), + opts.minSeverity !== undefined ? $.SeverityNumber.gte(opts.minSeverity) : undefined, + CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)), + CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)), + CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)), environmentCondition($, opts), namespaceCondition($, opts), + ...logAttributeConditions(opts), ]) .groupBy("name") @@ -377,11 +413,13 @@ export function logsCountQuery(opts: LogsQueryOpts): CHQuery $.ServiceName.eq(v)), CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)), + opts.minSeverity !== undefined ? $.SeverityNumber.gte(opts.minSeverity) : undefined, CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)), CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)), CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)), environmentCondition($, opts), namespaceCondition($, opts), + ...logAttributeConditions(opts), ]) .format("JSON") return raw as unknown as CHQuery @@ -480,7 +518,7 @@ export function logsListQuery(opts: LogsListOpts) { $.Timestamp.lte(param.dateTime("endTime")), CH.when(opts.serviceName, (v: string) => $.ServiceName.eq(v)), CH.when(opts.severity, (v: string) => $.SeverityText.eq(v)), - CH.when(opts.minSeverity, (v: number) => $.SeverityNumber.gte(v)), + opts.minSeverity !== undefined ? $.SeverityNumber.gte(opts.minSeverity) : undefined, CH.when(opts.traceId, (v: string) => $.TraceId.eq(v)), CH.when(opts.spanId, (v: string) => $.SpanId.eq(v)), CH.when(opts.cursor, (v: string) => $.Timestamp.lt(v)), @@ -490,14 +528,14 @@ export function logsListQuery(opts: LogsListOpts) { $.ServiceName.gt(opts.cursorIdentity.serviceName).or( $.ServiceName.eq(opts.cursorIdentity.serviceName).and( $.TraceId.gt(opts.cursorIdentity.traceId).or( - $.TraceId.eq(opts.cursorIdentity.traceId).and( - $.SpanId.gt(opts.cursorIdentity.spanId).or( - $.SpanId.eq(opts.cursorIdentity.spanId).and( - logRecordIdentity($).gt(opts.cursorIdentity.recordIdentity), + $.TraceId.eq(opts.cursorIdentity.traceId).and( + $.SpanId.gt(opts.cursorIdentity.spanId).or( + $.SpanId.eq(opts.cursorIdentity.spanId).and( + logRecordIdentity($).gt(opts.cursorIdentity.recordIdentity), + ), ), ), ), - ), ), ), ), @@ -506,6 +544,7 @@ export function logsListQuery(opts: LogsListOpts) { CH.when(opts.search, (v: string) => $.Body.ilike(`%${v}%`)), environmentCondition($, opts), namespaceCondition($, opts), + ...logAttributeConditions(opts), ] // Stage 1: cheap scan — only `Timestamp` is read. Compiled with placeholders diff --git a/packages/query-engine/src/ch/queries/query-helpers.ts b/packages/query-engine/src/ch/queries/query-helpers.ts index 3902475e1..93c0e13ec 100644 --- a/packages/query-engine/src/ch/queries/query-helpers.ts +++ b/packages/query-engine/src/ch/queries/query-helpers.ts @@ -93,6 +93,7 @@ interface TracesMatchModes { export interface TracesBaseWhereOpts { serviceName?: string spanName?: string + statusCode?: "Ok" | "Error" | "Unset" rootOnly?: boolean errorsOnly?: boolean environments?: readonly string[] @@ -175,6 +176,7 @@ export function tracesBaseWhereConditions( .or(CH.positionCaseInsensitive(display, CH.lit(v)).gt(0)) : $.SpanName.eq(v).or(display.eq(v)) }), + CH.when(opts.statusCode, (v: string) => $.StatusCode.eq(v)), CH.whenTrue(!!opts.rootOnly, () => $.SpanKind.in_("Server", "Consumer").or($.ParentSpanId.eq(""))), errorsOnlyCondition($.StatusCode, opts.errorsOnly), ] diff --git a/packages/query-engine/src/ch/queries/traces.test.ts b/packages/query-engine/src/ch/queries/traces.test.ts index bcd671bf3..84d503c33 100644 --- a/packages/query-engine/src/ch/queries/traces.test.ts +++ b/packages/query-engine/src/ch/queries/traces.test.ts @@ -16,7 +16,7 @@ const baseParams = { } describe("traceSummariesQuery", () => { - it("uses the trace-list ordering key with org/time filters and deterministic pagination", () => { + it("matches any span through a filtered semi-join and paginates root summaries deterministically", () => { const { sql } = compileCH( traceSummariesQuery({ serviceName: "api", @@ -27,17 +27,25 @@ describe("traceSummariesQuery", () => { baseParams, ) expect(sql).toContain("FROM trace_list_mv") - expect(sql).toContain("OrgId = 'org_1'") - expect(sql).toContain("Timestamp >= '2024-01-01 00:00:00'") - expect(sql).toContain("Timestamp <= '2024-01-02 00:00:00'") + expect(sql.match(/OrgId = 'org_1'/g)).toHaveLength(2) + expect(sql.match(/Timestamp >= '2024-01-01 00:00:00'/g)).toHaveLength(2) + expect(sql.match(/Timestamp <= '2024-01-02 00:00:00'/g)).toHaveLength(2) + expect(sql).toMatch(/TraceId IN \(SELECT\s+TraceId AS traceId/) + expect(sql).toContain("FROM traces") expect(sql).toContain("ServiceName = 'api'") - expect(sql).toContain("HasError = 1") + expect(sql).toContain("StatusCode = 'Error'") expect(sql).toContain("GROUP BY traceId") expect(sql).toContain("ORDER BY startTime DESC, traceId DESC") expect(sql).toContain("LIMIT 21") expect(sql).toContain("Timestamp < '2024-01-01 12:00:00'") expect(sql).toContain("TraceId < 'trace123'") }) + + it("restricts the matching subquery to root spans when spanScope=root", () => { + const { sql } = compileCH(traceSummariesQuery({ serviceName: "api", spanScope: "root" }), baseParams) + expect(sql).toMatch(/TraceId IN \(SELECT\s+TraceId AS traceId/) + expect(sql).toContain("SpanKind IN ('Server', 'Consumer')") + }) }) // --------------------------------------------------------------------------- diff --git a/packages/query-engine/src/ch/queries/traces.ts b/packages/query-engine/src/ch/queries/traces.ts index 5c643af13..a8e3df8a4 100644 --- a/packages/query-engine/src/ch/queries/traces.ts +++ b/packages/query-engine/src/ch/queries/traces.ts @@ -767,7 +767,7 @@ export interface TracesRootListOutput { readonly hasError: number } -export interface TraceSummariesOpts { +export interface TraceSummariesOpts extends TracesBaseWhereOpts { serviceName?: string spanName?: string hasError?: boolean @@ -777,6 +777,8 @@ export interface TraceSummariesOpts { httpStatusCode?: string deploymentEnv?: string namespace?: string + spanScope?: "root" + httpRoute?: string limit?: number offset?: number cursor?: { timestamp: string; traceId: string } @@ -801,8 +803,47 @@ export interface TraceSummaryOutput { const argMin = (value: CH.Expr, ordering: CH.Expr): CH.Expr => compileFnCall("argMin", value, ordering) -/** Public trace catalog read over the root-span MV, ordered deterministically. */ +/** + * Public trace catalog read over the root-span MV, ordered deterministically. + * Signal filters match any span by default. A tenant/time-filtered `IN` + * semi-join selects owning TraceIds before the root-summary MV is read, avoiding + * a broad runtime JOIN while keeping the returned fields explicitly root-scoped. + */ export function traceSummariesQuery(opts: TraceSummariesOpts) { + const attributeFilters = [ + ...(opts.attributeFilters ?? []), + ...(opts.httpMethod ? [{ key: "http.method", mode: "equals" as const, value: opts.httpMethod }] : []), + ...(opts.httpRoute ? [{ key: "http.route", mode: "equals" as const, value: opts.httpRoute }] : []), + ...(opts.httpStatusCode + ? [{ key: "http.status_code", mode: "equals" as const, value: opts.httpStatusCode }] + : []), + ] + const spanFilters: TracesBaseWhereOpts = { + serviceName: opts.serviceName, + spanName: opts.spanName, + statusCode: opts.statusCode, + rootOnly: opts.spanScope === "root" ? true : undefined, + errorsOnly: opts.hasError, + environments: opts.environments ?? (opts.deploymentEnv ? [opts.deploymentEnv] : undefined), + namespaces: opts.namespaces ?? (opts.namespace ? [opts.namespace] : undefined), + minDurationMs: opts.minDurationMs, + maxDurationMs: opts.maxDurationMs, + attributeFilters, + resourceAttributeFilters: opts.resourceAttributeFilters, + } + const hasSpanFilters = Object.entries(spanFilters).some(([, value]) => + Array.isArray(value) ? value.length > 0 : value !== undefined, + ) + const matchingTraceIds = hasSpanFilters + ? from(Traces) + .select(($) => ({ traceId: $.TraceId })) + .where(($) => tracesBaseWhereConditions($, spanFilters)) + .groupBy("traceId") + : undefined + const matchingTraceIdsSql = matchingTraceIds + ? compileCH(matchingTraceIds, {}, { skipFormat: true }).sql + : undefined + return from(TraceListMv) .select(($) => ({ traceId: $.TraceId, @@ -823,16 +864,7 @@ export function traceSummariesQuery(opts: TraceSummariesOpts) { $.OrgId.eq(param.string("orgId")), $.Timestamp.gte(param.dateTime("startTime")), $.Timestamp.lte(param.dateTime("endTime")), - CH.when(opts.serviceName, (value: string) => $.ServiceName.eq(value)), - CH.when(opts.spanName, (value: string) => $.SpanName.eq(value)), - CH.when(opts.hasError, () => $.HasError.eq(1)), - CH.when(opts.hasError === false, () => $.HasError.eq(0)), - opts.minDurationMs !== undefined ? $.Duration.gte(opts.minDurationMs * 1000000) : undefined, - opts.maxDurationMs !== undefined ? $.Duration.lte(opts.maxDurationMs * 1000000) : undefined, - CH.when(opts.httpMethod, (value: string) => $.HttpMethod.eq(value)), - CH.when(opts.httpStatusCode, (value: string) => $.HttpStatusCode.eq(value)), - CH.when(opts.deploymentEnv, (value: string) => $.DeploymentEnv.eq(value)), - CH.when(opts.namespace, (value: string) => $.ServiceNamespace.eq(value)), + matchingTraceIdsSql ? CH.rawCond(`TraceId IN (${matchingTraceIdsSql})`) : undefined, opts.cursor ? $.Timestamp.lt(opts.cursor.timestamp).or( $.Timestamp.eq(opts.cursor.timestamp).and($.TraceId.lt(opts.cursor.traceId)), diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts index 21cf2a0c1..cb710d4d4 100644 --- a/packages/query-engine/src/runtime/query-engine.ts +++ b/packages/query-engine/src/runtime/query-engine.ts @@ -496,12 +496,33 @@ const validateListQuery = Effect.fn("QueryEngineService.validateListQuery")(func function hasNarrowingFilter(request: QueryEngineExecuteRequest): boolean { if (!("filters" in request.query) || !request.query.filters) return false const filters = request.query.filters as Record - if (filters.serviceName || filters.spanName || filters.metricName || filters.traceId) return true - if (filters.errorsOnly || filters.rootOnly) return true + if ( + filters.serviceName || + filters.spanName || + filters.metricName || + filters.traceId || + filters.spanId || + filters.statusCode || + filters.severity || + filters.search || + filters.minDurationMs !== undefined || + filters.maxDurationMs !== undefined || + filters.minSeverity !== undefined || + filters.errorsOnly !== undefined || + filters.rootOnly + ) { + return true + } const envs = filters.environments if (Array.isArray(envs) && envs.length > 0) return true const services = filters.services if (Array.isArray(services) && services.length > 0) return true + const namespaces = filters.namespaces + if (Array.isArray(namespaces) && namespaces.length > 0) return true + const attributeFilters = filters.attributeFilters + if (Array.isArray(attributeFilters) && attributeFilters.length > 0) return true + const resourceAttributeFilters = filters.resourceAttributeFilters + if (Array.isArray(resourceAttributeFilters) && resourceAttributeFilters.length > 0) return true return false } @@ -867,6 +888,7 @@ function extractTracesOpts(filters: Record | undefined) { return { serviceName: filters?.serviceName as string | undefined, spanName: filters?.spanName as string | undefined, + statusCode: filters?.statusCode as "Ok" | "Error" | "Unset" | undefined, rootOnly: filters?.rootSpansOnly as boolean | undefined, errorsOnly: filters?.errorsOnly as boolean | undefined, environments: filters?.environments as string[] | undefined, @@ -892,6 +914,22 @@ function extractTracesOpts(filters: Record | undefined) { } } +function extractLogsOpts(filters: Record | undefined) { + return { + serviceName: filters?.serviceName as string | undefined, + severity: filters?.severity as string | undefined, + minSeverity: filters?.minSeverity as number | undefined, + traceId: filters?.traceId as string | undefined, + spanId: filters?.spanId as string | undefined, + search: filters?.search as string | undefined, + environments: filters?.environments as string[] | undefined, + namespaces: filters?.namespaces as string[] | undefined, + matchModes: logsMatchModes(filters), + attributeFilters: filters?.attributeFilters as AttrFilterArray | undefined, + resourceAttributeFilters: filters?.resourceAttributeFilters as AttrFilterArray | undefined, + } +} + /** * Map TracesFilters to the flat opts format expected by tracesFacetsQuery / tracesDurationStatsQuery. * TracesFilters stores http filters as attributeFilters entries; facets opts want them as top-level fields. @@ -1107,15 +1145,12 @@ export const makeQueryEngineExecute = (warehouse: QueryEn } if (request.query.source === "logs" && request.query.kind === "timeseries") { + const opts = extractLogsOpts(request.query.filters as Record | undefined) const rows = yield* executeCHQuery( warehouse, tenant, CH.logsTimeseriesQuery({ - serviceName: request.query.filters?.serviceName, - severity: request.query.filters?.severity, - environments: request.query.filters?.environments, - namespaces: request.query.filters?.namespaces, - matchModes: logsMatchModes(request.query.filters), + ...opts, groupBy: request.query.groupBy as string[] | undefined, bucketSeconds: bucketSeconds!, seriesLimit: request.query.seriesLimit, @@ -1358,16 +1393,13 @@ export const makeQueryEngineExecute = (warehouse: QueryEn } if (request.query.source === "logs" && request.query.kind === "breakdown") { + const opts = extractLogsOpts(request.query.filters as Record | undefined) const rows = yield* executeCHQuery( warehouse, tenant, CH.logsBreakdownQuery({ groupBy: request.query.groupBy as "service" | "severity", - serviceName: request.query.filters?.serviceName, - severity: request.query.filters?.severity, - environments: request.query.filters?.environments, - namespaces: request.query.filters?.namespaces, - matchModes: logsMatchModes(request.query.filters), + ...opts, limit: request.query.limit, }), { orgId: tenant.orgId, startTime: request.startTime, endTime: request.endTime }, @@ -1860,15 +1892,12 @@ export const computeEvaluateBuckets = Effect.fnUntraced(function* | undefined) const rows = yield* executeCHQuery( warehouse, tenant, CH.logsTimeseriesQuery({ - serviceName: query.filters?.serviceName, - severity: query.filters?.severity, - environments: query.filters?.environments, - namespaces: query.filters?.namespaces, - matchModes: logsMatchModes(query.filters), + ...opts, groupBy: query.groupBy as readonly string[] | undefined, bucketSeconds, }), @@ -2024,15 +2053,12 @@ export const makeQueryEngineEvaluate = (warehouse: QueryE } } else if (request.query.source === "logs") { const logsQuery = request.query + const opts = extractLogsOpts(request.query.filters as Record | undefined) const rows = yield* executeCHQuery( warehouse, tenant, CH.logsTimeseriesQuery({ - serviceName: request.query.filters?.serviceName, - severity: request.query.filters?.severity, - environments: request.query.filters?.environments, - namespaces: request.query.filters?.namespaces, - matchModes: logsMatchModes(request.query.filters), + ...opts, groupBy: logsQuery.groupBy as readonly string[] | undefined, bucketSeconds, }), diff --git a/packages/query-engine/src/traces-shared.ts b/packages/query-engine/src/traces-shared.ts index aa576068c..6fc13bfbb 100644 --- a/packages/query-engine/src/traces-shared.ts +++ b/packages/query-engine/src/traces-shared.ts @@ -100,7 +100,7 @@ export function httpDisplaySpanName( export function buildAttrFilterCondition( af: AttributeFilter, - mapName: "SpanAttributes" | "ResourceAttributes", + mapName: "SpanAttributes" | "LogAttributes" | "ResourceAttributes", ): CH.Condition { const mapExpr = CH.dynamicColumn>(mapName) // Span attributes renamed across OTel semconv versions match either spelling,