From 2e4dd94f0358c0da35d35fc865123e6f621a9890 Mon Sep 17 00:00:00 2001 From: Paul Cioanca Date: Fri, 23 Oct 2026 12:40:41 +0000 Subject: [PATCH 01/10] feat(webapp): label database spans with the Prisma client they use Database query spans from the webapp now carry a db.client_type attribute naming the database client that ran the query, the same name used on the webapp's database metrics. Query latency can now be broken down by query type (INSERT, UPDATE, SELECT, ...) for each client. Mono-RevId: 6dead141fe60243a6f6b7bb9ea4847920dd288fb --- apps/webapp/app/db.server.ts | 74 +++------ .../app/utils/datasourceTagging.server.ts | 89 +++++++++++ apps/webapp/app/v3/tracer.server.ts | 20 +-- apps/webapp/test/datasourceTagging.test.ts | 149 ++++++++++++++++++ 4 files changed, 260 insertions(+), 72 deletions(-) create mode 100644 apps/webapp/app/utils/datasourceTagging.server.ts create mode 100644 apps/webapp/test/datasourceTagging.test.ts diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index b690b394f07..3aa47bd23d3 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -47,9 +47,9 @@ import { runOpsTransactionResilience, } from "./v3/transactionResilience.server"; import { assertControlPlaneCoresidencyAdvisory } from "./v3/runOpsMigration/controlPlaneCoresidencySentinel.server"; -import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server"; +import { startActiveSpan } from "./v3/tracer.server"; +import { tagDatasource, tagDatasourceRunOps } from "./utils/datasourceTagging.server"; import type { Span } from "@opentelemetry/api"; -import { context, trace } from "@opentelemetry/api"; import { queryPerformanceMonitor } from "./utils/queryPerformanceMonitor.server"; export type { @@ -164,53 +164,6 @@ async function $transactionInner( export { Prisma, boundedIn }; -type DatasourceLabel = - | "control-plane-writer" - | "control-plane-replica" - | "legacy-run-ops-writer" - | "legacy-run-ops-replica" - | "run-ops-writer" - | "run-ops-replica" - | "webhook-writer" - | "webhook-partitions" - | "webhook-replica" - | "alert-partitions"; - -function tagDatasource(datasource: DatasourceLabel, client: T): T { - return client.$extends({ - name: "datasource-tagger", - query: { - $allOperations: ({ query, args }) => { - trace.getActiveSpan()?.setAttribute("db.datasource", datasource); - return context.with( - context.active().setValue(DATASOURCE_CONTEXT_KEY, datasource), - async () => await query(args) - ); - }, - }, - }) as unknown as T; -} - -// Same extension as tagDatasource but typed for RunOpsPrismaClient (different -// generated package — does not extend @trigger.dev/database.PrismaClient). -function tagDatasourceRunOps( - datasource: DatasourceLabel, - client: RunOpsPrismaClient -): RunOpsPrismaClient { - return client.$extends({ - name: "datasource-tagger", - query: { - $allOperations: ({ query, args }) => { - trace.getActiveSpan()?.setAttribute("db.datasource", datasource); - return context.with( - context.active().setValue(DATASOURCE_CONTEXT_KEY, datasource), - async () => await query(args) - ); - }, - }, - }) as unknown as RunOpsPrismaClient; -} - // Same wrapper as captureInfrastructureErrors, bridged via double cast because // that helper is constrained to T extends @trigger.dev/database.PrismaClient. function captureInfraErrorsRunOps(client: RunOpsPrismaClient): RunOpsPrismaClient { @@ -221,7 +174,9 @@ function captureInfraErrorsRunOps(client: RunOpsPrismaClient): RunOpsPrismaClien export const prisma = singleton("prisma", () => registerTransactionResilience( - captureInfrastructureErrors(tagDatasource("control-plane-writer", getClient())), + captureInfrastructureErrors( + tagDatasource("control-plane-writer", "control-plane-writer", getClient()) + ), controlPlaneTransactionResilience ) ); @@ -232,7 +187,9 @@ export const $replica: PrismaReplicaClient = singleton("replica", () => { // No replica configured → fall back to the writer `prisma`, which must stay UNBRANDED. return replica ? markReadReplicaClient( - captureInfrastructureErrors(tagDatasource("control-plane-replica", replica)) + captureInfrastructureErrors( + tagDatasource("control-plane-replica", "control-plane-replica", replica) + ) ) : prisma; }); @@ -248,6 +205,7 @@ export const webhookPrisma: WebhookDatabase = singleton("webhookPrisma", () => { } return captureInfrastructureErrors( tagDatasource( + "webhook-writer", "webhook-writer", buildWriterClient({ url: env.WEBHOOK_DATABASE_URL, @@ -269,6 +227,7 @@ export const webhookPartitionPrisma: WebhookDatabase = singleton("webhookPartiti } return captureInfrastructureErrors( tagDatasource( + "webhook-partitions", "webhook-partitions", buildWriterClient({ url: env.WEBHOOK_DATABASE_DIRECT_URL, @@ -283,6 +242,7 @@ export const webhookPartitionPrisma: WebhookDatabase = singleton("webhookPartiti export function createAlertPartitionPrisma(): PrismaClient { return captureInfrastructureErrors( tagDatasource( + "alert-partitions", "alert-partitions", buildWriterClient({ url: env.DATABASE_URL, @@ -304,6 +264,7 @@ export const webhookReplica: WebhookReplicaDatabase = singleton("webhookReplica" captureInfrastructureErrors( tagDatasource( "webhook-replica", + "webhook-reader", buildReplicaClient({ url: env.WEBHOOK_DATABASE_READ_REPLICA_URL, clientType: "webhook-reader", @@ -429,7 +390,7 @@ export function selectRunOpsTopology( // assertRunOpsSplitSentinel(). Because the builder callbacks only run when // splitEnabled is true, single-DB reuses prisma/$replica by reference and opens // nothing new. The builders apply the SAME wrapper pair the control-plane -// singletons use (captureInfrastructureErrors(tagDatasource(role, raw))). +// singletons use (captureInfrastructureErrors(tagDatasource(datasource, clientType, raw))). const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { const newUrl = env.RUN_OPS_DATABASE_URL; // Gate on the opt-in flag too: the distinct-DB sentinel only runs when the flag is on. @@ -486,6 +447,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfraErrorsRunOps( tagDatasourceRunOps( "run-ops-writer", + clientType, buildRunOpsClient({ url, clientType, @@ -507,6 +469,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfraErrorsRunOps( tagDatasourceRunOps( "run-ops-replica", + clientType, buildRunOpsClient({ url, clientType, @@ -526,6 +489,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfrastructureErrors( tagDatasource( "legacy-run-ops-writer", + clientType, buildWriterClient({ url, clientType, @@ -542,6 +506,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfrastructureErrors( tagDatasource( "legacy-run-ops-replica", + clientType, buildReplicaClient({ url, clientType, @@ -554,7 +519,8 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { ), // A gen-2 shard is a dedicated run-ops DB, so it mirrors buildNewWriter/buildNewReplica: same // client class, same wrapper stack, its OWN resilience budget, and the "new"-role pool knobs - // merged with the descriptor's per-shard overrides. Shards share the run-ops datasource tag. + // merged with the descriptor's per-shard overrides. Shards share the run-ops datasource tag; + // the db.client_type span attribute (the clientType) tells them apart. buildShardWriter: (shard) => { const descriptor = shardDescriptorsByKey.get(shard.key); const knobs = resolveRunOpsPoolKnobs("new", descriptor?.knobs); @@ -562,6 +528,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfraErrorsRunOps( tagDatasourceRunOps( "run-ops-writer", + `run-ops-shard-${shard.key}-writer`, buildRunOpsClient({ url: shard.url!, clientType: `run-ops-shard-${shard.key}-writer`, @@ -583,6 +550,7 @@ const runOpsTopology: RunOpsTopology = singleton("runOpsTopology", () => { captureInfraErrorsRunOps( tagDatasourceRunOps( "run-ops-replica", + `run-ops-shard-${shard.key}-replica`, buildRunOpsClient({ url: shard.replicaUrl!, clientType: `run-ops-shard-${shard.key}-replica`, diff --git a/apps/webapp/app/utils/datasourceTagging.server.ts b/apps/webapp/app/utils/datasourceTagging.server.ts new file mode 100644 index 00000000000..a123f37c39b --- /dev/null +++ b/apps/webapp/app/utils/datasourceTagging.server.ts @@ -0,0 +1,89 @@ +import { type Context, type Span, context, createContextKey, trace } from "@opentelemetry/api"; +import type { SpanProcessor } from "@opentelemetry/sdk-trace-base"; +import type { RunOpsPrismaClient } from "@internal/run-ops-database"; +import type { PrismaClient } from "@trigger.dev/database"; + +const DATASOURCE_CONTEXT_KEY = createContextKey("trigger.db.datasource"); +const CLIENT_TYPE_CONTEXT_KEY = createContextKey("trigger.db.client_type"); + +export class DatasourceAttributeSpanProcessor implements SpanProcessor { + onStart(span: Span, parentContext: Context): void { + const ds = parentContext.getValue(DATASOURCE_CONTEXT_KEY); + if (typeof ds === "string") { + span.setAttribute("db.datasource", ds); + } + const clientType = parentContext.getValue(CLIENT_TYPE_CONTEXT_KEY); + if (typeof clientType === "string") { + span.setAttribute("db.client_type", clientType); + } + } + onEnd(): void {} + shutdown(): Promise { + return Promise.resolve(); + } + forceFlush(): Promise { + return Promise.resolve(); + } +} + +export type DatasourceLabel = + | "control-plane-writer" + | "control-plane-replica" + | "legacy-run-ops-writer" + | "legacy-run-ops-replica" + | "run-ops-writer" + | "run-ops-replica" + | "webhook-writer" + | "webhook-partitions" + | "webhook-replica" + | "alert-partitions"; + +export function tagDatasource( + datasource: DatasourceLabel, + clientType: string, + client: T +): T { + return client.$extends({ + name: "datasource-tagger", + query: { + $allOperations: ({ query, args }) => { + trace + .getActiveSpan() + ?.setAttributes({ "db.datasource": datasource, "db.client_type": clientType }); + return context.with( + context + .active() + .setValue(DATASOURCE_CONTEXT_KEY, datasource) + .setValue(CLIENT_TYPE_CONTEXT_KEY, clientType), + async () => await query(args) + ); + }, + }, + }) as unknown as T; +} + +// Same extension as tagDatasource but typed for RunOpsPrismaClient (different +// generated package — does not extend @trigger.dev/database.PrismaClient). +export function tagDatasourceRunOps( + datasource: DatasourceLabel, + clientType: string, + client: RunOpsPrismaClient +): RunOpsPrismaClient { + return client.$extends({ + name: "datasource-tagger", + query: { + $allOperations: ({ query, args }) => { + trace + .getActiveSpan() + ?.setAttributes({ "db.datasource": datasource, "db.client_type": clientType }); + return context.with( + context + .active() + .setValue(DATASOURCE_CONTEXT_KEY, datasource) + .setValue(CLIENT_TYPE_CONTEXT_KEY, clientType), + async () => await query(args) + ); + }, + }, + }) as unknown as RunOpsPrismaClient; +} diff --git a/apps/webapp/app/v3/tracer.server.ts b/apps/webapp/app/v3/tracer.server.ts index 7047d65d2b9..a53a06a849f 100644 --- a/apps/webapp/app/v3/tracer.server.ts +++ b/apps/webapp/app/v3/tracer.server.ts @@ -2,7 +2,6 @@ import { type Attributes, type Context, context as otelContext, - createContextKey, DiagConsoleLogger, DiagLogLevel, type Link, @@ -70,27 +69,10 @@ import { logger } from "~/services/logger.server"; import { metricsRegister } from "~/metrics.server"; import { collectDatabaseClientMetrics } from "~/utils/databaseMetrics.server"; import { performance } from "node:perf_hooks"; +import { DatasourceAttributeSpanProcessor } from "~/utils/datasourceTagging.server"; export const SEMINTATTRS_FORCE_RECORDING = "forceRecording"; -export const DATASOURCE_CONTEXT_KEY = createContextKey("trigger.db.datasource"); - -class DatasourceAttributeSpanProcessor implements SpanProcessor { - onStart(span: Span, parentContext: Context): void { - const ds = parentContext.getValue(DATASOURCE_CONTEXT_KEY); - if (typeof ds === "string") { - span.setAttribute("db.datasource", ds); - } - } - onEnd(): void {} - shutdown(): Promise { - return Promise.resolve(); - } - forceFlush(): Promise { - return Promise.resolve(); - } -} - // Mirrors name-prefixed spans into a second exporter; they still flow to the main one class SpanNamePrefixMirrorProcessor implements SpanProcessor { constructor( diff --git a/apps/webapp/test/datasourceTagging.test.ts b/apps/webapp/test/datasourceTagging.test.ts new file mode 100644 index 00000000000..0dc45935143 --- /dev/null +++ b/apps/webapp/test/datasourceTagging.test.ts @@ -0,0 +1,149 @@ +import { context, propagation, trace } from "@opentelemetry/api"; +import { registerInstrumentations } from "@opentelemetry/instrumentation"; +import { + InMemorySpanExporter, + type ReadableSpan, + SimpleSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { makeNShardRunOpsPostgresTest, postgresTest } from "@internal/testcontainers"; +import { RunOpsPrismaClient } from "@internal/run-ops-database"; +import { PrismaClient } from "@trigger.dev/database"; +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { describe, expect } from "vitest"; +import { + DatasourceAttributeSpanProcessor, + tagDatasource, + tagDatasourceRunOps, +} from "~/utils/datasourceTagging.server"; + +function setupTracing() { + trace.disable(); + context.disable(); + propagation.disable(); + + const exporter = new InMemorySpanExporter(); + const provider = new NodeTracerProvider({ + spanProcessors: [new DatasourceAttributeSpanProcessor(), new SimpleSpanProcessor(exporter)], + }); + provider.register(); + const unregister = registerInstrumentations({ + tracerProvider: provider, + instrumentations: [new PrismaInstrumentation()], + }); + + return { + exporter, + tracer: provider.getTracer("test-tracer"), + async shutdown() { + unregister(); + await provider.shutdown(); + trace.disable(); + context.disable(); + propagation.disable(); + }, + }; +} + +const runOpsShardTest = makeNShardRunOpsPostgresTest(1); + +type Labels = { datasource: unknown; client: unknown }; +const labelsOf = (span: ReadableSpan): Labels => ({ + datasource: span.attributes["db.datasource"], + client: span.attributes["db.client_type"], +}); + +function expectEveryQuerySpanLabelled(exporter: InMemorySpanExporter, expected: Labels[]) { + const spans = exporter.getFinishedSpans(); + const parentOf = new Map(spans.map((s) => [s.spanContext().spanId, s.parentSpanContext?.spanId])); + const isUnder = (span: ReadableSpan, ancestorId: string) => { + for (let id = parentOf.get(span.spanContext().spanId); id; id = parentOf.get(id)) { + if (id === ancestorId) return true; + } + return false; + }; + + const operations = spans.filter((s) => s.name === "prisma:client:operation"); + expect(operations.map(labelsOf)).toEqual(expected); + + operations.forEach((operation, i) => { + const below = spans.filter((s) => isUnder(s, operation.spanContext().spanId)); + expect( + below.some((s) => s.name === "prisma:engine:db_query"), + `no db_query span under operation ${i}` + ).toBe(true); + for (const span of below) { + expect(labelsOf(span), `${span.name} under operation ${i}`).toEqual(labelsOf(operation)); + } + }); +} + +const callerLabels = (exporter: InMemorySpanExporter) => + exporter + .getFinishedSpans() + .filter((s) => s.name === "caller") + .map(labelsOf); + +describe("datasource tagging", () => { + postgresTest( + "tagDatasource: two clients on one datasource are told apart by db.client_type", + async ({ postgresContainer }) => { + const tracing = setupTracing(); + const client = new PrismaClient({ + datasources: { db: { url: postgresContainer.getConnectionUri() } }, + }); + try { + const writer = tagDatasource("control-plane-writer", "control-plane-writer", client); + const other = tagDatasource("control-plane-writer", "control-plane-writer-2", client); + + await tracing.tracer.startActiveSpan("caller", async (span) => { + await writer.organization.findFirst(); + await other.organization.findFirst(); + span.end(); + }); + + expect(callerLabels(tracing.exporter)).toEqual([ + { datasource: undefined, client: undefined }, + ]); + expectEveryQuerySpanLabelled(tracing.exporter, [ + { datasource: "control-plane-writer", client: "control-plane-writer" }, + { datasource: "control-plane-writer", client: "control-plane-writer-2" }, + ]); + } finally { + await client.$disconnect(); + await tracing.shutdown(); + } + } + ); + + runOpsShardTest( + "tagDatasourceRunOps: gen-1 and shard clients share run-ops-writer and are told apart by db.client_type", + async ({ newUri, shardUris }) => { + const tracing = setupTracing(); + const gen1Client = new RunOpsPrismaClient({ datasources: { db: { url: newUri } } }); + const shardClient = new RunOpsPrismaClient({ datasources: { db: { url: shardUris[0]! } } }); + try { + const gen1 = tagDatasourceRunOps("run-ops-writer", "run-ops-writer", gen1Client); + const shardA = tagDatasourceRunOps("run-ops-writer", "run-ops-shard-a-writer", shardClient); + + await tracing.tracer.startActiveSpan("caller", async (span) => { + await gen1.taskRun.findFirst(); + await shardA.taskRun.findFirst(); + span.end(); + }); + + expect(callerLabels(tracing.exporter)).toEqual([ + { datasource: undefined, client: undefined }, + ]); + expectEveryQuerySpanLabelled(tracing.exporter, [ + { datasource: "run-ops-writer", client: "run-ops-writer" }, + { datasource: "run-ops-writer", client: "run-ops-shard-a-writer" }, + ]); + } finally { + await gen1Client.$disconnect(); + await shardClient.$disconnect(); + await tracing.shutdown(); + } + } + ); +}); From d5e9e262cc2f36072557421884b2dee6a8ec03c6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 23 Oct 2026 12:45:34 +0000 Subject: [PATCH 02/10] docs: rework the introduction around chat.agent, webhooks and production Reworked the docs introduction around building agents with `chat.agent`, webhooks, and running in production, with an expanded FAQ. Mono-RevId: b79d3f40d91d699c56406a750855012dca05262b --- docs/ai-chat/overview.mdx | 4 +- docs/introduction.mdx | 128 +++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 26 deletions(-) diff --git a/docs/ai-chat/overview.mdx b/docs/ai-chat/overview.mdx index ebfd7807cd9..aef54314cad 100644 --- a/docs/ai-chat/overview.mdx +++ b/docs/ai-chat/overview.mdx @@ -12,12 +12,12 @@ A `chat.agent` task takes `messages`, calls `streamText`, and returns the result ```ts trigger/chat.ts import { chat } from "@trigger.dev/sdk/ai"; -import { streamText, stepCountIs } from "ai"; +import { stepCountIs } from "ai"; import { anthropic } from "@ai-sdk/anthropic"; export const myChat = chat.agent({ id: "my-chat", - run: async ({ messages, signal }) => + run: async ({ messages, signal, streamText }) => streamText({ model: anthropic("claude-sonnet-4-5"), messages, diff --git a/docs/introduction.mdx b/docs/introduction.mdx index a938b19e031..50d4cd80785 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -1,7 +1,7 @@ --- title: "Trigger.dev docs" sidebarTitle: "Introduction" -description: "Build and run durable AI agents and workflows in TypeScript. Long-running tasks with retries, queues, observability, and elastic scaling. Open source and self-hostable." +description: "Build and run durable AI agents and workflows in TypeScript. Long-running tasks with retries, queues, webhooks, observability, and elastic scaling. Open source and self-hostable." mode: "center" --- @@ -11,8 +11,8 @@ import AgentSetupPrompt from "/snippets/agent-setup-prompt.mdx"; Go from zero to your first task in 3 minutes. - - Agent patterns, worked examples, and the tools to run them in production. + + Durable, multi-turn agents with `chat.agent`, from your frontend to Slack and Linear. Let Claude Code, Cursor and Windsurf write correct Trigger.dev code with the MCP server, Skills and rules. @@ -28,48 +28,124 @@ import AgentSetupPrompt from "/snippets/agent-setup-prompt.mdx"; ## What is Trigger.dev? -Trigger.dev is the open source platform for building and running durable AI agents and workflows in TypeScript. Run them for hours, configure automatic retries, control concurrency, and manage queues. There's no execution timeout, and no queue or worker infrastructure for you to build. +Trigger.dev is the open source platform for building and running durable AI agents and workflows in TypeScript. You write tasks and agents in your existing codebase, and they run on Trigger.dev with retries, queues, waits, full tracing, and elastic scaling. There's no execution timeout, and no queue or worker infrastructure for you to build. -You write agents and tasks in your existing codebase using our CLI and SDK, and get durable execution, queues, waits, full tracing, and elastic scaling without building any of it yourself. +Trigger.dev's core is Apache-2.0 licensed. [Self-host it](/self-hosting/overview) on your own infrastructure, or let [Trigger.dev Cloud](https://cloud.trigger.dev) run it for you. -Trigger.dev's core is Apache-2.0 licensed, a genuine OSI license. [Self-host it](/self-hosting/overview) on your own infrastructure, or let [Trigger.dev Cloud](https://cloud.trigger.dev) run it for you. +## Build agents with `chat.agent` -## Core concepts +A `chat.agent` runs each conversation as one durable task. It wakes when a message arrives and idles between turns, and its state survives page refreshes, deploys, and crashes. Turn lifecycle, resuming a stream mid-reply, and approvals are handled for you, and your frontend connects with the AI SDK's `useChat`, with no API routes to write. + +```ts trigger/chat.ts +import { chat } from "@trigger.dev/sdk/ai"; +import { anthropic } from "@ai-sdk/anthropic"; + +export const supportAgent = chat.agent({ + id: "support-agent", + run: async ({ messages, signal, streamText }) => + streamText({ + model: anthropic("claude-sonnet-4-5"), + messages, + abortSignal: signal, + }), +}); +``` + + + + Build a chat agent and its frontend end to end. + + + Each conversation's durable state and two-way stream, across all its runs. + + + Declare tools on your agent and get them back typed in `run()`. + + + Pause a tool call for approval and resume when a person decides. + + + Turn an agent into a native Slack agent with `@trigger.dev/slack`. + + + Assign Linear issues to an agent with `@trigger.dev/linear`. + + + +Version your [prompts](/ai/prompts) in code and change them from the dashboard, and see every model step and tool call, with tokens, cost and latency, in [AI observability](/ai/observability). + +## Run work - The durable functions at the core of Trigger.dev. Write them in your existing codebase, with retries and no timeouts. + The durable functions at the core of Trigger.dev, written in your existing codebase. - Trigger a task from your backend, your frontend, a schedule, or another task. + Trigger a task from your backend, your frontend, or another task. - Every execution of a task is a run, with its status, retries, and logs recorded. + Every execution of a task is a run, with its status, retries, logs and trace. + + + Stream run status and LLM output to your frontend with React hooks. + + + +## React to events + + + + Hosted, verified endpoints for Stripe, GitHub and more, delivered to your tasks and agents. + + + Run a task on a cron schedule, with timezone support. + + + Pause a run until a matching webhook arrives, like a payment confirmation. - - Use an idempotency key so a retried or duplicated trigger runs once, not twice. + + Pause a run for a duration, until a date, or until a token is completed. A waiting run doesn't use compute. -## Build with agents +## Run in production -Build durable, multi-turn agents with the [chat agent](/ai-chat/overview): one long-running task per conversation, with the tools it can call, streaming, and resumability handled for you. Add human-in-the-loop approval by pausing a run on a [waitpoint](/wait-for-token) until a person signs off, and a waiting run doesn't consume compute. Stream run status and LLM output straight to your frontend with the [Realtime API](/realtime/overview) and React hooks, and record your Vercel AI SDK calls as spans with the model, token counts, cost and latency using [AI observability](/ai/observability). Version your [prompts](/ai/prompts) in code, then edit the text or swap the model from the dashboard without redeploying. + + + Control how many runs execute at once, globally or per tenant. + + + Choose the CPU and memory each task runs on. + + + Atomic, versioned deploys, with preview branches and environments. + + + Retry failed attempts automatically, and decide what happens when a task throws. + + + A full trace of every run, with your logs and spans. + + + Query your runs with TRQL, and build dashboards from the results. + + -## Scale and scheduling +## Frequently asked questions -Set how many runs of a task execute at once, globally or per tenant, with [concurrency](/concurrency) and [queues](/queues). Run a task on a [cron schedule](/tasks/scheduled) with timezone support, choose the CPU and memory each task runs on with [machines](/machines), and control what happens when a task throws with [errors and retries](/errors-retrying). +**Can I use Trigger.dev for background jobs, not just AI?** Yes. The same durable tasks, queues, retries, and [cron schedules](/tasks/scheduled) run any background job or workflow, with or without AI. -## Self-hosting +**How long can a run take?** As long as it needs. Runs have no maximum duration by default, and you can set a [`maxDuration`](/runs/max-duration) for a project, a task, or a single run to cap it. -Trigger.dev is open source and you can self-host. Read the [overview guide](/self-hosting/overview), or how to do it with [Docker](/self-hosting/docker) or [Kubernetes](/self-hosting/kubernetes). +**Do my serverless function timeouts apply?** No. Tasks run on Trigger.dev, not inside your serverless functions, so your platform's function timeout isn't in the execution path. Your backend triggers the task and returns. -## Frequently asked questions +**Can I receive webhooks without running a server?** Yes. Declare a [webhook endpoint](/webhooks/overview) in your code, and Trigger.dev hosts its URL, verifies each request, and delivers the event to your tasks and agents. -**How do I add human-in-the-loop approval to an agent?** Pause the run on a [waitpoint](/wait-for-token) until a person approves the action. A waiting run doesn't consume compute, so it can wait minutes or days. +**How do I add human-in-the-loop approval?** Pause the run on a [waitpoint](/wait-for-token) until a person approves, or use a [chat agent's approvals](/ai-chat/patterns/human-in-the-loop) for tool calls. A waiting run doesn't use compute, so it can wait minutes or days. **Does Trigger.dev support tool calling and MCP?** Yes. Agents call the tools you define, and the [MCP server](/building-with-ai) lets AI coding assistants trigger tasks, deploy, and read run traces. -**Can I use Trigger.dev for background jobs, not just AI?** Yes. The same durable tasks, queues, retries, and [cron schedules](/tasks/scheduled) run any background job or workflow, with or without AI. +**Can I self-host?** Yes. Trigger.dev is open source. Read the [self-hosting overview](/self-hosting/overview), or set it up with [Docker](/self-hosting/docker) or [Kubernetes](/self-hosting/kubernetes). ## Getting help @@ -85,7 +161,7 @@ We'd love to hear from you or give you a hand getting started. Here are some way Our Discord is the best place to get help with any questions about Trigger.dev. @@ -112,7 +188,7 @@ We'd love to hear from you or give you a hand getting started. Here are some way href="https://github.com/triggerdotdev/trigger.dev" color="#fbbf24" > - Check us out our GitHub repo and give us a star if you like what we're doing. + Check out our GitHub repo, and give us a star if you like what we're doing. @@ -122,12 +198,16 @@ We'd love to hear from you or give you a hand getting started. Here are some way **Current major version:** v4 (`@trigger.dev/sdk` 4.x). Documentation under `/docs/v2` and `/docs/v3` describes superseded versions and should not be used. -**Execution model:** tasks run on Trigger.dev infrastructure, not inside your serverless functions, so a platform function timeout is not in the execution path. A task runs until it finishes; you can cap runtime with `maxDuration` (minimum 5 seconds). +**Execution model:** tasks run on Trigger.dev infrastructure, not inside your serverless functions, so a platform function timeout is not in the execution path. Runs have no maximum duration by default; `maxDuration` caps a run's compute time for a project, a task, or a single run, and `timeout.None` removes the cap. **Deploys:** each deploy is an atomic version. A run already in progress is not affected by code shipped after it started. **Durability and retries:** retries are automatic and configurable. A failed attempt is retried according to the task's retry settings; once retries are exhausted the run ends terminally as `FAILED`, and some crashes (for example out of memory) end the run as `CRASHED` without a retry. Idempotency keys deduplicate the task triggers and waits you key, so the same key runs once instead of again. +**Agents:** `chat.agent` is imported from `@trigger.dev/sdk/ai`, not from `@trigger.dev/sdk`. Inside `run()`, call the `streamText` passed in the run options. It records each model step and tool call as a span by default. Slack and Linear agents use the `@trigger.dev/slack` and `@trigger.dev/linear` packages, and `slack()` requires a `startOn` filter (`appMentions()` answers @mentions). + +**Webhooks:** `webhooks.endpoint.define()` declares a hosted, verified endpoint that syncs on `trigger dev` and on deploy; `webhook()` subscribes a task with a filter, and `webhooks.waiter` pauses a run until a matching event arrives. Both come from `@trigger.dev/sdk`. + **License:** the platform core is Apache-2.0 and the `@trigger.dev/sdk` package is MIT, permissive open source, not fair-code or source-available. Some features are gated, but the open source core is not relicensed or held back. **LLM observability coverage:** only Vercel AI SDK calls (`generateText`, `streamText`, `generateObject`) are recorded as spans. Raw `fetch` calls and provider SDKs used directly are not captured. From c5b123d5d9085c0db7ea7d3945bc28054392708c Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Fri, 23 Oct 2026 13:10:53 +0000 Subject: [PATCH 03/10] feat(alerts): query alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alerts can now watch the result of a query: raise when a value crosses a threshold, when matching rows stop appearing, or when new matching rows appear — with the observed values charted on the alert detail page. Mono-RevId: 6fef942e47bcf26a8a9d707149d99033858687d2 --- .env.schema | 2 +- .server-changes/query-alerts.md | 6 + .../alerts/AlertDetailChart.query.dom.test.ts | 331 ++++++++ .../components/alerts/AlertDetailChart.tsx | 130 ++- .../components/alerts/AlertThresholdChart.tsx | 59 +- .../app/components/alerts/AlertsList.tsx | 1 + .../app/components/alerts/GroupsDropdown.tsx | 63 ++ .../components/alerts/ManualAlertCreation.tsx | 29 +- .../alerts/QueryAlertForm.dom.test.ts | 429 ++++++++++ .../app/components/alerts/QueryAlertForm.tsx | 279 +++++++ .../alerts/QueryAlertTranscript.dom.test.ts | 377 +++++++++ .../alerts/QueryAlertTranscript.tsx | 477 +++++++++++ .../app/components/alerts/QueryBlock.tsx | 17 + .../components/alerts/QueryPreviewResult.tsx | 74 ++ .../alerts/WatchQueryButton.dom.test.ts | 182 +++++ .../components/alerts/WatchQueryButton.tsx | 58 ++ .../components/alerts/alertsListFilters.ts | 2 +- .../components/alerts/createAlertRequest.ts | 50 ++ .../app/components/alerts/creationCards.tsx | 20 +- .../app/components/alerts/domainIcons.ts | 5 +- .../app/components/alerts/queryAlertLink.ts | 133 ++++ .../components/alerts/queryGroupSelection.ts | 34 + .../components/alerts/useQueryAlertDraft.ts | 245 ++++++ .../dashboard-agent/ChatWizardCard.tsx | 2 + .../dashboard-agent/DashboardAgentPanel.tsx | 114 ++- .../alert-creation/ManualCreationForm.tsx | 2 +- .../alert-creation/draftPatch.test.ts | 8 +- .../alert-creation/draftPatch.ts | 26 +- .../alert-creation/flow-state.ts | 6 + .../queryDraftPatch.dom.test.ts | 163 ++++ .../alert-creation/useCreationAgentSend.ts | 11 +- .../components/dashboard-agent/tool-labels.ts | 1 + .../components/primitives/charts/Chart.tsx | 6 +- .../ChartLine.tooltipSwatch.render.test.ts | 49 ++ .../primitives/charts/ChartLine.tsx | 14 +- .../app/components/query/QueryEditor.tsx | 15 + apps/webapp/app/env.server.ts | 4 +- .../route.tsx | 25 +- .../route.tsx | 41 +- .../route.tsx | 18 +- ...d-agent.monitoring-alerts.analyze-query.ts | 52 ++ ...pi.v1.dashboard-agent.monitoring-alerts.ts | 152 ++-- ...jectRef.monitoring-alerts.analyze-query.ts | 66 ++ .../app/routes/resources.alerts.create.ts | 24 +- ...aram.env.$envParam.alerts.analyze-query.ts | 45 ++ ...aram.env.$envParam.alerts.preview-query.ts | 67 ++ .../dashboardAgentMonitoringAlerts.server.ts | 58 ++ .../app/services/queryService.server.ts | 29 +- .../webapp/app/v3/alertEngineWorker.server.ts | 2 +- apps/webapp/app/v3/detectQueryTables.ts | 8 +- .../app/v3/services/alerts/alertDraft.ts | 9 + .../v3/services/alerts/alertSeries.server.ts | 7 +- .../alerts/clickhouseDateTime.test.ts | 22 + .../v3/services/alerts/clickhouseDateTime.ts | 5 + .../alerts/createAlertFromDraft.server.ts | 89 ++- .../createMonitoringAlertsAction.server.ts | 46 ++ .../alerts/delivery/alertEventChatText.ts | 28 +- .../alerts/delivery/alertQueryRender.test.ts | 230 ++++++ .../alerts/delivery/alertSlackMessage.ts | 13 +- .../delivery/deliverAlertEvent.server.ts | 40 +- .../alerts/engine/evaluateAlert.server.ts | 85 +- .../productionEvaluateBinding.server.ts | 76 +- .../evaluators/queryEvaluator.server.ts | 185 ++++- .../alerts/healthQuerySeries.server.ts | 6 +- .../alerts/monitoringAlertsSchemas.ts | 14 +- .../v3/services/alerts/previewQuery.server.ts | 198 +++++ .../services/alerts/previewSeries.server.ts | 2 +- .../analyzeQueryForAlert.server.ts | 685 ++++++++++++++++ .../queryAlerts/analyzeQueryForAlert.test.ts | 753 ++++++++++++++++++ .../alerts/queryAlerts/matchLineage.ts | 214 +++++ .../queryAlerts/queryAlertPrefill.server.ts | 105 +++ .../queryAlerts/queryAlertSeries.server.ts | 248 ++++++ .../services/alerts/queryAlerts/queryAst.ts | 148 ++++ .../resolveAlertCapability.server.ts | 168 ++++ .../alerts/queryAlerts/timeLineage.ts | 73 ++ .../alerts/validateAlertDraft.server.ts | 35 +- .../test/alertCapabilitiesCreateEdit.test.ts | 14 +- .../test/alertsAnalyzeQueryRoute.test.ts | 138 ++++ .../test/alertsPreviewQueryRoute.test.ts | 605 ++++++++++++++ .../test/alertsQueryAlertLoader.test.ts | 167 ++++ apps/webapp/test/alertsQueryEvaluator.test.ts | 708 ++++++++++++++++ apps/webapp/test/alertsQueryLane.test.ts | 206 +++++ apps/webapp/test/alertsQuerySeries.test.ts | 395 +++++++++ .../dashboardAgentMonitoringAlerts.test.ts | 188 +++++ apps/webapp/test/monitoringAlertsApi.test.ts | 154 ++++ .../test/resourcesAlertsCreateQuery.test.ts | 421 ++++++++++ .../alert-contracts/src/contracts.test.ts | 103 ++- .../alert-contracts/src/index.ts | 11 + .../alert-contracts/src/querySources.ts | 104 +++ .../alert-contracts/src/registry.ts | 15 + .../alert-contracts/src/schemas.ts | 24 +- .../clickhouse/src/client/tsql.ts | 4 + .../src/alert-context.test.ts | 28 + .../src/alert-context.ts | 17 +- .../__snapshots__/prompt-prefix.test.ts.snap | 32 +- .../dashboard-agent/src/prompt-prefix.test.ts | 4 +- .../src/tool-monitoring-alerts.test.ts | 113 ++- .../src/tool-monitoring-alerts.ts | 40 + .../dashboard-agent/src/tool-schemas.ts | 17 +- .../dashboard-agent/src/tools.ts | 5 +- .../emails/emails/alert-raised.tsx | 10 +- .../emails/src/alertRaisedContent.ts | 73 ++ internal-packages/tsql/src/index.test.ts | 18 + internal-packages/tsql/src/index.ts | 149 +++- internal-packages/tsql/src/query/printer.ts | 22 +- .../tsql/src/query/printer_context.ts | 27 +- .../tsql/src/query/security.test.ts | 74 ++ packages/cli-v3/src/apiClient.ts | 18 + 108 files changed, 11124 insertions(+), 275 deletions(-) create mode 100644 .server-changes/query-alerts.md create mode 100644 apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts create mode 100644 apps/webapp/app/components/alerts/GroupsDropdown.tsx create mode 100644 apps/webapp/app/components/alerts/QueryAlertForm.dom.test.ts create mode 100644 apps/webapp/app/components/alerts/QueryAlertForm.tsx create mode 100644 apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts create mode 100644 apps/webapp/app/components/alerts/QueryAlertTranscript.tsx create mode 100644 apps/webapp/app/components/alerts/QueryBlock.tsx create mode 100644 apps/webapp/app/components/alerts/QueryPreviewResult.tsx create mode 100644 apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts create mode 100644 apps/webapp/app/components/alerts/WatchQueryButton.tsx create mode 100644 apps/webapp/app/components/alerts/queryAlertLink.ts create mode 100644 apps/webapp/app/components/alerts/queryGroupSelection.ts create mode 100644 apps/webapp/app/components/alerts/useQueryAlertDraft.ts create mode 100644 apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts create mode 100644 apps/webapp/app/components/primitives/charts/ChartLine.tooltipSwatch.render.test.ts create mode 100644 apps/webapp/app/routes/api.v1.dashboard-agent.monitoring-alerts.analyze-query.ts create mode 100644 apps/webapp/app/routes/api.v1.projects.$projectRef.monitoring-alerts.analyze-query.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.analyze-query.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.preview-query.ts create mode 100644 apps/webapp/app/services/dashboardAgentMonitoringAlerts.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/clickhouseDateTime.test.ts create mode 100644 apps/webapp/app/v3/services/alerts/clickhouseDateTime.ts create mode 100644 apps/webapp/app/v3/services/alerts/delivery/alertQueryRender.test.ts create mode 100644 apps/webapp/app/v3/services/alerts/previewQuery.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/analyzeQueryForAlert.test.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/matchLineage.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/queryAlertPrefill.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/queryAlertSeries.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/queryAst.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/resolveAlertCapability.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/queryAlerts/timeLineage.ts create mode 100644 apps/webapp/test/alertsAnalyzeQueryRoute.test.ts create mode 100644 apps/webapp/test/alertsPreviewQueryRoute.test.ts create mode 100644 apps/webapp/test/alertsQueryAlertLoader.test.ts create mode 100644 apps/webapp/test/alertsQueryEvaluator.test.ts create mode 100644 apps/webapp/test/alertsQueryLane.test.ts create mode 100644 apps/webapp/test/alertsQuerySeries.test.ts create mode 100644 apps/webapp/test/resourcesAlertsCreateQuery.test.ts create mode 100644 internal-packages/alert-contracts/src/querySources.ts diff --git a/.env.schema b/.env.schema index 88faa7013e0..b4a7b2c74f2 100644 --- a/.env.schema +++ b/.env.schema @@ -205,7 +205,7 @@ POSTHOG_PROJECT_KEY= # @sensitive=false ALERTS_ENABLED=0 # @sensitive=false -ALERT_QUERY_CONCURRENCY=1 +ALERTS_QUERY_CONCURRENCY_LIMIT=2 # @sensitive=false ALERT_FAILURE_RATE_MIN_VOLUME=10 # @sensitive=false diff --git a/.server-changes/query-alerts.md b/.server-changes/query-alerts.md new file mode 100644 index 00000000000..cd04ec4d053 --- /dev/null +++ b/.server-changes/query-alerts.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Alerts can now watch the result of any supported query: raise when a value crosses a threshold, when matching rows stop appearing, or when new matching rows appear. Create them from the Query page, the Dashboard Agent or the API, and see the alert's values charted on its page. diff --git a/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts b/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts new file mode 100644 index 00000000000..caf51f48333 --- /dev/null +++ b/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts @@ -0,0 +1,331 @@ +// @vitest-environment jsdom +import { act, createElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { AlertDetailChart } from "./AlertDetailChart"; +import { AREA_OPTIONS } from "./AlertsList"; +import { WARNING_COLOR } from "./ChartLegend"; +import { AREA_ICON, AREA_LABEL, isAlertArea } from "./domainIcons"; +import type { MetricSeriesPoint } from "./alertSeriesTypes"; +import { defaultGroupSelection } from "./queryGroupSelection"; +import type * as AlertThresholdChartModule from "./AlertThresholdChart"; + +const { assignGroupColors, AlertMultiSeriesChart, SERIES_COLORS, thresholdChartLegend } = + await vi.importActual("./AlertThresholdChart"); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; + +vi.mock("~/components/primitives/charts/ChartCompound", () => ({ + Chart: { + Root: ({ + children, + series, + config, + }: { + children: ReactNode; + series: string[]; + config: Record; + }) => + createElement( + "div", + { + "data-series": series.join(","), + "data-colors": Object.values(config) + .map((c) => c.color) + .join(","), + }, + children + ), + Line: ({ warningOverlay }: { warningOverlay?: unknown }) => + createElement("i", { "data-overlay": JSON.stringify(warningOverlay) }), + }, +})); +vi.mock("~/components/primitives/DateTime", () => ({ useUserTimeZone: () => "UTC" })); +vi.mock("~/components/runs/v3/SharedFilters", () => ({ TimeFilter: () => null })); +vi.mock("~/components/primitives/DetailChartBlock", () => ({ + DetailChartBlock: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("./PreviewChartCard", () => ({ + PreviewChartCard: ({ accessory, children }: { accessory?: ReactNode; children: ReactNode }) => + createElement("div", null, accessory, children), +})); +vi.mock("./ActivityBars", async (importOriginal) => ({ + ...(await importOriginal()), + ActivityBars: ({ data }: { data: unknown[] }) => + createElement("i", { "data-chart": "bars", "data-points": data.length }), +})); +vi.mock("./AlertThresholdChart", async (importOriginal) => ({ + ...(await importOriginal()), + AlertMultiSeriesChart: ({ seriesMap }: { seriesMap: Map }) => + createElement("i", { "data-chart": "multi", "data-keys": [...seriesMap.keys()].join(",") }), +})); + +const HOUR = 60 * 60 * 1000; +const T0 = Date.UTC(2026, 0, 1); + +const alert = { + kind: "threshold", + capabilityTitle: "Custom", + capabilityKey: "custom.query", + operator: ">", + thresholdValue: 10, + scope: { type: "all" }, + status: { status: "active" }, + episode: { raisedAt: new Date(T0), outcome: "raised" }, + events: [], + currentReading: { status: "unavailable" }, +} as unknown as AlertDetail; + +const points = (...values: number[]): MetricSeriesPoint[] => + values.map((value, i) => ({ bucket: T0 + i * HOUR, value })); + +// g8 and g9 cross the threshold; the others stay below and rise with their index +const groups = Array.from({ length: 10 }, (_, i) => `g${i}`); +const seriesMap = new Map(groups.map((g, i) => [g, points(i >= 8 ? 50 + i : i, 1)])); + +let root: Root | undefined; +let container: HTMLElement | undefined; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + document.body.innerHTML = ""; +}); + +function mount(props: Record = {}) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + render(props); + return container; +} + +function render(props: Record = {}) { + act(() => + root!.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement( + ShortcutsProvider, + null, + createElement(AlertDetailChart, { + alert, + metricSeriesMap: seriesMap, + healthBands: [], + window: { since: new Date(T0), until: new Date(T0 + 4 * HOUR) }, + now: new Date(T0 + 4 * HOUR), + queryGroups: groups, + querySeriesMode: "threshold", + ...props, + } as never) + ) + ) + ) + ); +} + +const keys = (c: HTMLElement) => + c.querySelector("[data-chart=multi]")!.getAttribute("data-keys")!.split(","); + +describe("defaultGroupSelection", () => { + it("puts crossed groups first, then closest to the threshold, capped at 8", () => { + const picked = defaultGroupSelection({ groups, seriesMap, operator: ">" }); + expect(picked).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); + }); + + it("orders toward the threshold for a below-threshold operator", () => { + const picked = defaultGroupSelection({ + groups: ["a", "b", "c"], + seriesMap: new Map([ + ["a", points(5)], + ["b", points(1)], + ["c", points(3)], + ]), + operator: "<", + }); + expect(picked).toEqual(["b", "c", "a"]); + }); +}); + +describe("AlertDetailChart query groups", () => { + it("renders at most 8 series by default, crossed groups first", () => { + const c = mount(); + expect(keys(c)).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); + expect(c.textContent).toContain("Groups 8/10"); + }); + + it("hides a series when its group is toggled off and searches the list", () => { + const c = mount(); + act(() => c.querySelector("button")!.click()); + + const search = document.querySelector("input")!; + expect(search.placeholder).toContain("Up to 8 visible"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + const type = (value: string) => + act(() => { + setter.call(search, value); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + const rows = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent); + const g0 = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g0" + )!; + expect(g0.getAttribute("aria-disabled")).toBe("true"); + type("g1"); + expect(rows()).toEqual(["g1"]); + + type(""); + const row = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g9" + )!; + act(() => row.click()); + expect(keys(c)).not.toContain("g9"); + expect(c.textContent).toContain("Groups 7/10"); + }); + + it("shows the picker only with at least two groups", () => { + expect(mount({ queryGroups: ["g9"] }).textContent).not.toContain("Groups"); + act(() => root?.unmount()); + container?.remove(); + expect(mount({ queryGroups: ["g9", "g8"] }).textContent).toContain("Groups 2/2"); + act(() => container!.querySelector("button")!.click()); + expect(document.querySelector("input")!.placeholder).not.toContain("Up to"); + }); + + it("shows the only remaining group even after it was deselected", () => { + const c = mount({ queryGroups: ["g9", "g8"] }); + act(() => c.querySelector("button")!.click()); + const g9 = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g9" + )!; + act(() => g9.click()); + expect(keys(c)).toEqual(["g8"]); + + render({ queryGroups: ["g9"] }); + expect(keys(c)).toEqual(["g9"]); + expect(c.textContent).not.toContain("Groups"); + }); + + it("renders the occurrence bar chart in match mode", () => { + const c = mount({ + querySeriesMode: "match", + metricSeriesMap: new Map([["__agg__", points(2, 3, 4)]]), + }); + expect(c.querySelector("[data-chart=bars]")!.getAttribute("data-points")).toBe("3"); + expect(c.querySelector("[data-chart=multi]")).toBeNull(); + }); +}); + +describe("Watching area for custom queries", () => { + it("shows Custom with the Query page icon", () => { + expect(AREA_LABEL.query).toBe("Custom"); + expect(AREA_ICON.query).toBe(CodeSquareIcon); + expect(isAlertArea("query")).toBe(true); + expect(AREA_OPTIONS).toContainEqual({ value: "query", label: "Custom" }); + }); +}); + +function hue(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [ + number, + number, + number, + ]; + const max = Math.max(r, g, b); + const d = max - Math.min(r, g, b); + if (d === 0) return 0; + const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + return (h * 60 + 360) % 360; +} + +describe("grouped chart threshold highlight", () => { + it("keeps yellow and amber out of the series colours", () => { + expect(SERIES_COLORS).toHaveLength(8); + for (const color of SERIES_COLORS) { + const h = hue(color); + expect(h < 20 || h > 75, color).toBe(true); + } + }); + + it("applies the warning overlay to the chart holding every group", () => { + const html = renderToStaticMarkup( + createElement(AlertMultiSeriesChart, { + seriesMap: new Map([ + ["a", points(1, 20, 25)], + ["b", points(2, 30, 15)], + ]), + threshold: 10, + operator: ">", + }) + ); + expect(html).toContain('data-series="a,b"'); + expect(html).toContain(""direction":"above""); + expect(html).toContain("var(--color-warning)"); + }); +}); + +describe("assignGroupColors", () => { + const keys = Array.from({ length: 12 }, (_, i) => `g${i}`); + + it("gives 8 visible groups 8 distinct colours", () => { + const colors = assignGroupColors(new Map(), keys.slice(0, 8)); + expect(new Set(colors.values()).size).toBe(8); + }); + + it("stays distinct for 12 groups with 8 selected", () => { + const colors = assignGroupColors(new Map(), keys.slice(2, 10)); + expect(colors.size).toBe(8); + expect(new Set(colors.values()).size).toBe(8); + }); + + it("keeps untouched colours when one is toggled off and another on", () => { + const first = assignGroupColors(new Map(), keys.slice(0, 8)); + const next = assignGroupColors(first, [...keys.slice(1, 8), "g10"]); + expect(new Set(next.values()).size).toBe(8); + for (const key of keys.slice(1, 8)) expect(next.get(key)).toBe(first.get(key)); + expect(next.get("g10")).toBe(first.get("g0")); + }); +}); + +describe("grouped chart legend", () => { + const seriesMap = new Map([ + ["a", points(1, 20)], + ["b", points(2, 30)], + ]); + + it("lists every colour it draws, warning entry included", () => { + const input = { seriesMap, label: "x", threshold: 10, operator: ">" as const }; + const legend = thresholdChartLegend(input); + expect(legend.map((i) => i.label)).toEqual(["a", "b", "Above threshold"]); + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[0], SERIES_COLORS[1], WARNING_COLOR]); + }); + + it("omits it without a threshold", () => { + const labels = thresholdChartLegend({ seriesMap, label: "x", threshold: null }).map( + (i) => i.label + ); + expect(labels).toEqual(["a", "b"]); + }); + + it("keeps a lone group's assigned colour in the line and the legend", () => { + const colors = new Map([["b", SERIES_COLORS[1]!]]); + const single = new Map([["b", points(2, 30)]]); + const html = renderToStaticMarkup( + createElement(AlertMultiSeriesChart, { seriesMap: single, threshold: 10, colors }) + ); + expect(html).toContain(`data-colors="${SERIES_COLORS[1]}"`); + const legend = thresholdChartLegend({ seriesMap: single, label: "x", threshold: null, colors }); + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[1]]); + }); +}); diff --git a/apps/webapp/app/components/alerts/AlertDetailChart.tsx b/apps/webapp/app/components/alerts/AlertDetailChart.tsx index c485d358c0c..6c4b5a43919 100644 --- a/apps/webapp/app/components/alerts/AlertDetailChart.tsx +++ b/apps/webapp/app/components/alerts/AlertDetailChart.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useUserTimeZone } from "~/components/primitives/DateTime"; import { DetailChartBlock } from "~/components/primitives/DetailChartBlock"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server"; import { HealthViewToggle, type HealthView } from "./HealthViewToggle"; +import { GroupsDropdown } from "./GroupsDropdown"; import { PreviewChartCard } from "./PreviewChartCard"; import { alertChartInfo } from "./alertSummaries"; import { ActivityBars, EVENT_CHART_COLOR } from "./ActivityBars"; @@ -38,8 +39,10 @@ import { import { AlertMultiSeriesChart, AlertThresholdChart, + assignGroupColors, thresholdChartLegend, } from "./AlertThresholdChart"; +import { defaultGroupSelection, MAX_VISIBLE_GROUPS } from "./queryGroupSelection"; const METRIC_LABEL: Record = { "queue.depth": "Depth", @@ -90,12 +93,47 @@ type AlertDetailChartProps = { window: DetailWindow & { isLifetime?: boolean; explicitTo?: boolean }; now: Date; createdAt?: Date; + queryGroups?: string[]; + querySeriesMode?: "threshold" | "absence" | "match"; }; export function AlertDetailChart(props: AlertDetailChartProps) { const [healthView, setHealthView] = useState("together"); - const { alert, window } = props; + const [picked, setPicked] = useState(null); + const { alert, window, metricSeriesMap, queryGroups = [] } = props; const showToggle = alert.kind === "state" && (props.componentSeries?.length ?? 0) > 1; + const grouped = + alert.kind === "threshold" && props.querySeriesMode !== "match" && queryGroups.length > 0; + const defaults = useMemo( + () => + grouped + ? defaultGroupSelection({ + groups: queryGroups, + seriesMap: metricSeriesMap, + operator: alert.operator, + range: { since: window.since.getTime(), until: window.until.getTime() }, + }) + : [], + [grouped, queryGroups, metricSeriesMap, alert.operator, window.since, window.until] + ); + const showPicker = grouped && queryGroups.length > 1; + const selected = showPicker ? (picked ?? defaults) : defaults; + // Colours carry over from the previous selection, so a toggle doesn't repaint other groups. + const [colorState, setColorState] = useState({ key: "", colors: new Map() }); + const selectedKey = JSON.stringify(selected); + let groupColors = colorState.colors; + if (colorState.key !== selectedKey) { + groupColors = assignGroupColors(colorState.colors, selected); + setColorState({ key: selectedKey, colors: groupColors }); + } + const toggleGroup = (group: string) => + setPicked( + selected.includes(group) + ? selected.filter((g) => g !== group) + : selected.length >= MAX_VISIBLE_GROUPS + ? selected + : [...selected, group] + ); return ( + ) : showPicker ? ( + ) : undefined } /> @@ -125,15 +172,50 @@ function AlertDetailChartBody({ now, createdAt, healthView, + selectedGroups, + groupColors, + querySeriesMode, accessory, -}: AlertDetailChartProps & { healthView: HealthView; accessory?: React.ReactNode }) { +}: AlertDetailChartProps & { + healthView: HealthView; + selectedGroups?: string[]; + groupColors?: Map; + accessory?: React.ReactNode; +}) { const title = alertChartTitle(alert); const info = alertChartInfo(alert); const raisedAt = alert.episode.raisedAt; const timeZone = useUserTimeZone(); + if (querySeriesMode === "match") { + const occurrences = (metricSeriesMap.get("__agg__") ?? []).map((p) => ({ + bucket: p.bucket, + count: p.value ?? 0, + })); + return ( + + ); + } + if (alert.kind === "threshold") { - const raw = pickThresholdSeries(metricSeriesMap, alert.scope.type); + const raw = selectedGroups + ? { + multi: true as const, + seriesMap: new Map( + selectedGroups.flatMap((g) => { + const points = metricSeriesMap.get(g); + return points ? [[g, points] as const] : []; + }) + ), + } + : pickThresholdSeries(metricSeriesMap, alert.scope.type); const start = firstDataAt(window, createdAt, { series: raw.multi ? [...raw.seriesMap.values()] : [raw.series], events: alert.events, @@ -162,6 +244,7 @@ function AlertDetailChartBody({ operator={alert.operator} raisedAt={raisedAt} timeZone={timeZone} + colors={groupColors} fill /> ) : ( @@ -186,6 +269,7 @@ function AlertDetailChartBody({ label, threshold, operator: alert.operator, + colors: groupColors, })} alwaysShowControls accessory={accessory} @@ -199,6 +283,7 @@ function AlertDetailChartBody({ operator={alert.operator} raisedAt={raisedAt} timeZone={timeZone} + colors={groupColors} fill showLegend /> @@ -296,10 +381,39 @@ function AlertDetailChartBody({ ); } - const occurrences = (metricSeriesMap.get("__agg__") ?? []).map((p) => ({ - bucket: p.bucket, - count: p.value ?? 0, - })); + return ( + ({ + bucket: p.bucket, + count: p.value ?? 0, + }))} + window={window} + createdAt={createdAt} + timeZone={timeZone} + accessory={accessory} + /> + ); +} + +function OccurrenceBarsCard({ + alert, + occurrences, + window, + createdAt, + timeZone, + accessory, +}: { + alert: AlertDetail; + occurrences: { bucket: number; count: number }[]; + window: DetailWindow; + createdAt?: Date; + timeZone: string; + accessory?: React.ReactNode; +}) { + const raisedAt = alert.episode.raisedAt; + const title = alertChartTitle(alert); + const info = alertChartInfo(alert); const start = firstDataAt(window, createdAt, { series: [occurrences.map((p) => ({ bucket: p.bucket, value: p.count }))], events: alert.events, diff --git a/apps/webapp/app/components/alerts/AlertThresholdChart.tsx b/apps/webapp/app/components/alerts/AlertThresholdChart.tsx index 226cb8fa55a..f75e7f5c6a2 100644 --- a/apps/webapp/app/components/alerts/AlertThresholdChart.tsx +++ b/apps/webapp/app/components/alerts/AlertThresholdChart.tsx @@ -41,7 +41,17 @@ export function thresholdWarningOverlay(threshold: number | null, operator?: ">" }; } -const SERIES_COLORS = [EVENT_CHART_COLOR, "#6366F1", "#EC4899", "#F59E0B", "#10B981"]; +// Yellow/amber is reserved for the over-threshold highlight (WARNING_COLOR). +export const SERIES_COLORS = [ + EVENT_CHART_COLOR, + "#6366F1", + "#EC4899", + "#10B981", + "#06B6D4", + "#3B82F6", + "#D946EF", + "#14B8A6", +]; const OTHER_COLOR = "var(--color-text-dimmed)"; function orderedSeriesKeys(seriesMap: Map): string[] { @@ -50,13 +60,37 @@ function orderedSeriesKeys(seriesMap: Map): string[] { return keys; } -function multiSeriesConfig(keys: string[]): Record { +export function assignGroupColors( + prev: Map, + selected: string[] +): Map { + const next = new Map(); + for (const key of selected) { + const color = prev.get(key); + if (color) next.set(key, color); + } + const used = new Set(next.values()); + const free = SERIES_COLORS.filter((c) => !used.has(c)); + for (const key of selected) { + if (!next.has(key)) + next.set(key, free.shift() ?? SERIES_COLORS[next.size % SERIES_COLORS.length]!); + } + return next; +} + +function multiSeriesConfig( + keys: string[], + colors?: Map +): Record { return Object.fromEntries( keys.map((key, i) => [ key, { label: key === "__other__" ? "Other" : key, - color: key === "__other__" ? OTHER_COLOR : SERIES_COLORS[i % SERIES_COLORS.length]!, + color: + key === "__other__" + ? OTHER_COLOR + : (colors?.get(key) ?? SERIES_COLORS[i % SERIES_COLORS.length]!), }, ]) ); @@ -67,17 +101,21 @@ function thresholdChartParts({ label, threshold, operator, + colors, }: { seriesMap?: Map; label: string; threshold: number | null; operator?: ">" | "<"; + colors?: Map; }) { const keys = seriesMap ? orderedSeriesKeys(seriesMap) : []; const multi = keys.length > 1; return { - config: multi ? multiSeriesConfig(keys) : { value: { label, color: EVENT_CHART_COLOR } }, - overlay: multi ? undefined : thresholdWarningOverlay(threshold, operator), + config: multi + ? multiSeriesConfig(keys, colors) + : { value: { label, color: colors?.get(keys[0] ?? "") ?? EVENT_CHART_COLOR } }, + overlay: thresholdWarningOverlay(threshold, operator), referenceLineColor: threshold === null ? undefined : THRESHOLD_LINE_COLOR, }; } @@ -87,6 +125,7 @@ export function thresholdChartLegend(input: { label: string; threshold: number | null; operator?: ">" | "<"; + colors?: Map; }): ChartLegendItem[] { const { config, overlay } = thresholdChartParts(input); return [ @@ -120,6 +159,7 @@ export function AlertThresholdChart({ operator, raisedAt, timeZone, + color, }: { series: MetricSeriesPoint[]; threshold: number | null; @@ -130,6 +170,7 @@ export function AlertThresholdChart({ operator?: ">" | "<"; raisedAt?: Date | null; timeZone?: string; + color?: string; }) { if (!hasReadings(series)) { return ; @@ -137,7 +178,7 @@ export function AlertThresholdChart({ const data = series.map((p) => ({ bucket: p.bucket, value: p.value })); - const config: ChartConfig = thresholdChartParts({ label, threshold, operator }).config; + const config: ChartConfig = { value: { label, color: color ?? EVENT_CHART_COLOR } }; const { tickFormatter, tooltipLabelFormatter } = buildActivityTimeAxis( data, @@ -205,6 +246,7 @@ export function AlertMultiSeriesChart({ operator, raisedAt, timeZone, + colors, }: { seriesMap: Map; threshold: number | null; @@ -216,6 +258,7 @@ export function AlertMultiSeriesChart({ operator?: ">" | "<"; raisedAt?: Date | null; timeZone?: string; + colors?: Map; }) { const keys = orderedSeriesKeys(seriesMap); if (keys.length === 0 || keys.every((k) => !hasReadings(seriesMap.get(k) ?? []))) { @@ -235,6 +278,7 @@ export function AlertMultiSeriesChart({ operator={operator} raisedAt={raisedAt} timeZone={timeZone} + color={colors?.get(keys[0]!)} /> ); } @@ -249,7 +293,7 @@ export function AlertMultiSeriesChart({ merged.push(point); } - const configEntries = multiSeriesConfig(keys); + const configEntries = multiSeriesConfig(keys, colors); const { tickFormatter, tooltipLabelFormatter } = buildActivityTimeAxis( merged, @@ -294,6 +338,7 @@ export function AlertMultiSeriesChart({ }, ] } + warningOverlay={thresholdWarningOverlay(threshold, operator)} xAxisProps={{ tickFormatter: (value: number) => value === raiseTick ? `▲ raised ${tickFormatter(value)}` : tickFormatter(value), diff --git a/apps/webapp/app/components/alerts/AlertsList.tsx b/apps/webapp/app/components/alerts/AlertsList.tsx index 01a2e0f02d0..0858fa0aef1 100644 --- a/apps/webapp/app/components/alerts/AlertsList.tsx +++ b/apps/webapp/app/components/alerts/AlertsList.tsx @@ -173,6 +173,7 @@ export const AREA_OPTIONS: { value: AreaFilterValue; label: string }[] = [ { value: "errors", label: "Errors" }, { value: "runs", label: "Runs" }, { value: "health", label: "Health" }, + { value: "query", label: "Custom" }, { value: "unknown", label: "Unknown" }, ]; diff --git a/apps/webapp/app/components/alerts/GroupsDropdown.tsx b/apps/webapp/app/components/alerts/GroupsDropdown.tsx new file mode 100644 index 00000000000..01f24f84df2 --- /dev/null +++ b/apps/webapp/app/components/alerts/GroupsDropdown.tsx @@ -0,0 +1,63 @@ +import { Select, SelectItem } from "~/components/primitives/Select"; +import { MAX_VISIBLE_GROUPS } from "./queryGroupSelection"; + +type GroupsProps = { + groups: string[]; + selected: string[]; + onToggle: (group: string) => void; + max?: number; + colors?: Map; +}; + +export function GroupsDropdown({ + groups, + selected, + onToggle, + max = MAX_VISIBLE_GROUPS, + colors, +}: GroupsProps) { + const full = selected.length >= max; + + return ( + + ); +} diff --git a/apps/webapp/app/components/alerts/ManualAlertCreation.tsx b/apps/webapp/app/components/alerts/ManualAlertCreation.tsx index ee362d28fbe..5b2622971c6 100644 --- a/apps/webapp/app/components/alerts/ManualAlertCreation.tsx +++ b/apps/webapp/app/components/alerts/ManualAlertCreation.tsx @@ -9,6 +9,8 @@ import { replaceRequestBody, } from "~/components/alerts/createAlertRequest"; import { LEGACY_ALERTS_ROUTE_ID } from "~/components/alerts/legacyAlertChannels"; +import { QueryAlertForm } from "~/components/alerts/QueryAlertForm"; +import type { QueryAlertPrefill } from "~/components/alerts/queryAlertLink"; import type { AlertChannel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; import { useCreationFlowState, @@ -151,12 +153,14 @@ function ManualAlertCreation({ /* oxlint-disable jsx-a11y/no-static-element-interactions -- Escape bubbles from focused controls inside the panel. */ function ManualAlertCreationPanel({ prefill, + queryPrefill, alertDetailPath, onCreated, onReplaced, onClose, }: { prefill: DraftPrefillParams; + queryPrefill?: QueryAlertPrefill; alertDetailPath: (alertId: string) => string; onCreated: (alertId: string | undefined) => void; onReplaced?: (result: ReplacedResult) => void; @@ -190,13 +194,21 @@ function ManualAlertCreationPanel({ />
- + {queryPrefill ? ( + + ) : ( + + )}
@@ -208,6 +220,7 @@ function ManualAlertCreationPanel({ export function ManualAlertCreationDock({ open, prefill, + queryPrefill, alertDetailPath, onCreated, onReplaced, @@ -216,6 +229,7 @@ export function ManualAlertCreationDock({ }: { open: boolean; prefill: DraftPrefillParams; + queryPrefill?: QueryAlertPrefill; alertDetailPath: (alertId: string) => string; onCreated: (alertId: string | undefined) => void; onReplaced?: (result: ReplacedResult) => void; @@ -262,6 +276,7 @@ export function ManualAlertCreationDock({ > ({ + ...(await importOriginal()), + useInRouterContext: () => false, + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/", search: "" }), + useFetcher: () => { + const [url, setUrl] = useState(); + return { + data: url?.startsWith("/resources/alerts/channels") + ? { channels: [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }], canWrite: true } + : undefined, + load: setUrl, + }; + }, +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOptionalOrganization: () => ({ slug: "org", id: "org1" }), + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useOptionalProject: () => ({ slug: "proj", id: "proj1" }), + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); +vi.mock("~/hooks/useTypedMatchData", () => ({ useTypedMatchesData: () => undefined })); +vi.mock("~/hooks/useFeatures", () => ({ useFeatures: () => ({ queueMetricsEnabled: true }) })); + +const PREVIEW_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/preview-query"; +let previewResponse = () => new Response("{}"); +let createResponse = () => new Response(JSON.stringify({ ok: true, alertId: "a1" })); +const fetchMock = vi.fn(async (url: string | URL | Request) => { + if (String(url) === "/resources/alerts/create") return createResponse(); + if (String(url) === PREVIEW_PATH) return previewResponse(); + return new Response("{}"); +}); + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const QUERY = "SELECT count() AS failures, avg(duration) AS took FROM runs"; +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; +const FAILURES = { column: "failures", type: "UInt64", countLike: true }; +const TOOK = { column: "took", type: "Float64", countLike: false }; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +function thresholdAnalysis(measures = [FAILURES], absenceAllowed = true) { + return analysis({ + modes: [{ mode: "threshold", shape: "scalar", measures, groupColumns: [], absenceAllowed }], + }); +} + +function prefill(partial: Partial = {}): QueryAlertPrefill { + return { + query: QUERY, + period: "7d", + analysis: thresholdAnalysis(), + names: { + "threshold:failures": "failures on runs", + "absence:failures": "No failures on runs", + "threshold:took": "took on runs", + }, + ...partial, + }; +} + +const fieldset = (step: string) => + document.querySelector(`fieldset[data-step="${step}"]`); +const combobox = (step: string, label?: string) => + Array.from( + fieldset(step)?.querySelectorAll("button[role=combobox]") ?? [] + ).find((b) => !label || b.getAttribute("aria-label") === label) ?? null; +const popover = (select: HTMLElement) => + document.getElementById(select.getAttribute("aria-controls") ?? ""); +const options = (select: HTMLElement) => + Array.from(popover(select)?.querySelectorAll("[role=option]") ?? []); +const click = (el: HTMLElement) => act(() => el.click()); +function openSelect(select: HTMLElement) { + act(() => select.focus()); + act(() => select.click()); +} +function optionLabels(step: string, label?: string) { + const select = combobox(step, label)!; + openSelect(select); + const labels = options(select).map((o) => o.textContent); + act(() => select.click()); + return labels; +} +function pick(step: string, label: string, aria?: string) { + const select = combobox(step, aria)!; + openSelect(select); + const found = options(select).find((o) => o.textContent === label); + if (!found) throw new Error(`no ${label}: ${options(select).map((o) => o.textContent)}`); + click(found); +} +const checkbox = (step: string, label: string) => + Array.from(fieldset(step)!.querySelectorAll("[data-checkbox-list] label")) + .find((l) => l.textContent === label)! + .querySelector("input")!; +const button = (label: string) => + Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label + ); +const input = (label: string) => + document.querySelector(`input[aria-label="${label}"]`)!; +function type(el: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +let container: HTMLElement; +let root: Root; +const onCreated = vi.fn(); + +function render(queryPrefill: QueryAlertPrefill) { + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement( + ManualAlertCreationDock, + { + open: true, + prefill: {}, + queryPrefill, + alertDetailPath: (id: string) => `/alerts/${id}`, + onCreated, + onClose: vi.fn(), + }, + createElement("div", { "data-testid": "page" }, "page") + ) + ) + ) + ) + ); +} + +describe("QueryAlertForm", () => { + beforeEach(() => { + fetchMock.mockClear(); + onCreated.mockClear(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("shows the query read-only as the first section", () => { + render(prefill()); + const form = document.querySelector("[data-testid=query-alert-form]")!; + const first = form.firstElementChild as HTMLElement; + expect(first.getAttribute("data-step")).toBe("query"); + expect(first.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(first.querySelector("input, textarea")).toBeNull(); + }); + + it("hides Signal with one measure and shows it with two measures or two modes", () => { + render(prefill()); + expect(document.querySelector("[data-testid=query-alert-form]")).not.toBeNull(); + expect(fieldset("signal")).toBeNull(); + + render(prefill({ query: `${QUERY} `, analysis: thresholdAnalysis([FAILURES, TOOK]) })); + expect(optionLabels("signal")).toEqual(["failures", "took"]); + + render( + prefill({ + query: `${QUERY} `, + analysis: analysis({ + modes: [ + ...thresholdAnalysis().modes, + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + expect(optionLabels("signal")).toEqual(["failures", "New runs rows"]); + }); + + it("offers the absence condition only when the analysis allows it", () => { + render(prefill()); + expect(optionLabels("condition", "Condition")).toEqual([ + "Value crosses a threshold", + "No matching results for the window", + ]); + + render(prefill({ query: `${QUERY} `, analysis: thresholdAnalysis([FAILURES], false) })); + expect(combobox("condition", "Condition")).toBeNull(); + expect(fieldset("condition")!.textContent).toContain("Value crosses a threshold"); + expect(fieldset("condition")!.textContent).not.toContain("No matching results"); + }); + + it("defaults the window to min(period, 24h) floored at the minimum cadence (catches a removed floor)", () => { + render(prefill({ period: "5m" })); + expect(input("Window").value).toBe("10"); + expect(fieldset("window")!.textContent).toContain("At least 10 min, at most 1440 min."); + + render(prefill({ query: `${QUERY} `, period: "7d" })); + expect(input("Window").value).toBe("1440"); + + render(prefill({ query: `${QUERY} `, period: "30m" })); + expect(input("Window").value).toBe("30"); + }); + + it("caps the default window at the analysis max window (catches a removed cap)", () => { + render( + prefill({ + analysis: { ...thresholdAnalysis(), limits: { ...LIMITS, maxWindowSec: 3600 } }, + }) + ); + expect(input("Window").value).toBe("60"); + expect(fieldset("window")!.textContent).toContain("at most 60 min."); + }); + + it.each([400, 422, 500])("shows the server error on a %i create response", async (status) => { + const message = "Quiet window must be at least 600 seconds"; + createResponse = () => new Response(JSON.stringify({ error: message }), { status }); + try { + render(prefill()); + type(input("Threshold"), "3"); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + await act(async () => button("Create alert")!.click()); + expect(document.body.textContent).toContain( + status === 500 ? "Failed to create the alert. Try again." : message + ); + expect(onCreated).not.toHaveBeenCalled(); + } finally { + createResponse = () => new Response(JSON.stringify({ ok: true, alertId: "a1" })); + } + }); + + it("has no Window in match mode and previews with the default lookback", async () => { + render( + prefill({ + period: "2h", + analysis: analysis({ + modes: [ + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + expect(fieldset("condition")!.textContent).toContain("New matching results appear"); + expect(fieldset("window")).toBeNull(); + previewResponse = () => + new Response(JSON.stringify({ ok: false, reason: "The query could not be evaluated." })); + await act(async () => button("Preview")!.click()); + const call = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse((call as unknown as [string, RequestInit])[1].body as string)).toEqual({ + query: QUERY, + mode: "match", + windowMinutes: 120, + }); + expect(document.body.textContent).toContain("The query could not be evaluated."); + }); + + it("blocks Create until the threshold is a number", () => { + render(prefill()); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + expect(button("Create alert")!.disabled).toBe(true); + type(input("Threshold"), "abc"); + expect(button("Create alert")!.disabled).toBe(true); + type(input("Threshold"), "3"); + expect(button("Create alert")!.disabled).toBe(false); + }); + + it("has no Frequency control", () => { + render( + prefill({ + analysis: analysis({ + modes: [ + ...thresholdAnalysis([FAILURES, TOOK]).modes, + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + const panel = document.querySelector("[data-testid=manual-alert-panel]")!; + expect(panel.textContent).not.toMatch(/frequency/i); + }); + + it("posts the query and the signal choice and reports the created id", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") }); + try { + render(prefill({ analysis: thresholdAnalysis([FAILURES, TOOK]) })); + pick("signal", "took"); + expect(input("Name").value).toBe("took on runs"); + type(input("Threshold"), "5"); + pick("condition", "<", "Operator"); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + await act(async () => button("Create alert")!.click()); + const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create"); + expect(calls).toHaveLength(1); + expect(JSON.parse((calls[0] as unknown as [string, RequestInit])[1].body as string)).toEqual({ + organizationId: "org1", + projectId: "proj1", + environmentId: "env1", + name: "took on runs", + expiry: "24h", + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind: "threshold", + operator: "<", + value: 5, + windowMinutes: 1440, + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "took" }, + expiresAt: "2026-01-02T00:00:00.000Z", + notifierChannelIds: ["ch1"], + }, + }); + expect(onCreated).toHaveBeenCalledWith("a1"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps an edited name when the signal changes", () => { + render(prefill({ analysis: thresholdAnalysis([FAILURES, TOOK]) })); + expect(input("Name").value).toBe("failures on runs"); + type(input("Name"), "My alert"); + pick("signal", "took"); + expect(input("Name").value).toBe("My alert"); + }); + + it("previews the alert and reports a busy lane", async () => { + render(prefill()); + pick("condition", "No matching results for the window", "Condition"); + previewResponse = () => + new Response( + JSON.stringify({ + ok: true, + mode: "absence", + value: 0, + wouldFire: true, + window: { minutes: 1440, from: "", to: "" }, + }) + ); + await act(async () => button("Preview")!.click()); + const call = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse((call as unknown as [string, RequestInit])[1].body as string)).toEqual({ + query: QUERY, + mode: "absence", + measure: "failures", + windowMinutes: 1440, + }); + expect(document.body.textContent).toContain("Matching rows in the window: 0"); + expect(document.body.textContent).toContain("Would fire now"); + + previewResponse = () => new Response(JSON.stringify({ error: "busy" }), { status: 429 }); + type(input("Window"), "60"); + await act(async () => button("Preview")!.click()); + expect(document.body.textContent).toContain("Another query is running, try again"); + }); + + it("shows the analyzer messages and a way back when the query has no alert mode", () => { + render( + prefill({ + analysis: analysis({ + ok: false, + errors: [{ code: "parse_error", message: "Syntax error near FROM." }], + }), + }) + ); + expect(document.querySelector("[data-testid=query-alert-form]")).toBeNull(); + const unavailable = document.querySelector("[data-testid=query-alert-unavailable]")!; + expect(unavailable.textContent).toContain("Syntax error near FROM."); + expect(unavailable.textContent).not.toContain("parse_error"); + const back = Array.from(unavailable.querySelectorAll("a")).find( + (a) => a.textContent === "Back to query" + )!; + expect(back.getAttribute("href")).toBe("/orgs/org/projects/proj/env/prod/query"); + }); +}); diff --git a/apps/webapp/app/components/alerts/QueryAlertForm.tsx b/apps/webapp/app/components/alerts/QueryAlertForm.tsx new file mode 100644 index 00000000000..156ef207c54 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertForm.tsx @@ -0,0 +1,279 @@ +import type { ReactNode } from "react"; +import { AlertChannelsDialog } from "~/components/alerts/AlertChannelsDialog"; +import { + QUERY_CONDITION_LABELS, + querySignalOptions, + watchBlockedReasons, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import { QueryBlock } from "~/components/alerts/QueryBlock"; +import { QueryPreviewResult } from "~/components/alerts/QueryPreviewResult"; +import { useQueryAlertDraft } from "~/components/alerts/useQueryAlertDraft"; +import { NO_NOTIFIER } from "~/components/alerts/wizardSteps"; +import { ManualStep } from "~/components/dashboard-agent/alert-creation/ManualCreationForm"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Input } from "~/components/primitives/Input"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { queryPath } from "~/utils/pathBuilder"; + +const SINGLE_FIELD = "w-full max-w-[372px]"; +const OPERATORS = [ + { id: ">", label: ">" }, + { id: "<", label: "<" }, +]; + +type Choice = { id: string; label: string }; + +function Section({ + id, + title, + hint, + children, +}: { + id: string; + title: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+ {title} + {children} + {hint ?

{hint}

: null} +
+ ); +} + +function ChoiceSelect({ + label, + value, + options, + onChange, + className = SINGLE_FIELD, +}: { + label: string; + value: string; + options: Choice[]; + onChange: (value: string) => void; + className?: string; +}) { + return ( + + ); +} + +function QueryAlertUnavailable({ reasons }: { reasons: string[] }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + return ( +
+ + {reasons.map((reason) => ( +

{reason}

+ ))} +
+ + Back to query + +
+ ); +} + +export function QueryAlertForm({ + prefill, + onCreated, +}: { + prefill: QueryAlertPrefill; + onCreated: (alertId: string | undefined) => void; +}) { + const reasons = watchBlockedReasons(prefill.analysis, prefill.query); + if (reasons.length || querySignalOptions(prefill.analysis).length === 0) { + return ; + } + return ; +} + +function QueryAlertFields({ + prefill, + onCreated, +}: { + prefill: QueryAlertPrefill; + onCreated: (alertId: string | undefined) => void; +}) { + const draft = useQueryAlertDraft(prefill, onCreated); + const { + analysis, + signals, + signal, + setSignalId, + modes, + mode, + setModeChoice, + operator, + setOperator, + valueText, + setValueText, + bounds, + windowText, + setWindowText, + windowValid, + name, + setEditedName, + flow, + addChannel, + addingChannel, + setAddingChannel, + expiryStep, + notifiersStep, + hasNotifier, + selectStep, + } = draft; + + return ( +
+
+ +
+ {signals.length > 1 ? ( +
+ +
+ ) : null} +
+ {modes.length > 1 ? ( + ({ id: m, label: QUERY_CONDITION_LABELS[m] }))} + onChange={(next) => setModeChoice(next as QueryAlertMode)} + /> + ) : ( +

{QUERY_CONDITION_LABELS[mode]}

+ )} + {mode === "threshold" ? ( +
+ setOperator(next as ">" | "<")} + className="w-16 shrink-0" + /> + setValueText(e.target.value)} + /> +
+ ) : null} +
+ {mode === "match" ? null : ( +
+
+ setWindowText(e.target.value)} + /> + min +
+
+ )} + + +
+ setEditedName(e.target.value)} + /> +
+ {draft.preview ? ( + + ) : null} + {draft.createError ? {draft.createError} : null} +
+ + +
+ {addingChannel ? ( + { + if (!flow.answers.notifiers?.includes(channel.id)) flow.toggleNotifier(channel.id); + }} + onClose={() => { + setAddingChannel(false); + addChannel.onCreated(); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts b/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts new file mode 100644 index 00000000000..5ae848fb06c --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts @@ -0,0 +1,377 @@ +// @vitest-environment jsdom +import { EyeIcon } from "@heroicons/react/20/solid"; +import type * as RemixReactModule from "@remix-run/react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement, useState, type ComponentProps, type ReactNode } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { QueryAlertTranscript, type QueryWatchPrefill } from "./QueryAlertTranscript"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + useInRouterContext: () => false, + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/", search: "" }), + useFetcher: () => { + const [url, setUrl] = useState(); + return { + data: url?.startsWith("/resources/alerts/channels") + ? { channels: [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }], canWrite: true } + : undefined, + load: setUrl, + }; + }, +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOptionalOrganization: () => ({ slug: "org", id: "org1" }), + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useOptionalProject: () => ({ slug: "proj", id: "proj1" }), + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); + +const ANALYZE_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/analyze-query"; +const PREVIEW_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/preview-query"; +const QUERY = "SELECT count() AS failures, avg(duration) AS took FROM runs"; +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; +const FAILURES = { column: "failures", type: "UInt64", countLike: true }; +const TOOK = { column: "took", type: "Float64", countLike: false }; +const MATCH = { + mode: "match" as const, + sourceId: "runs", + rowIdColumn: "run_id", + eventColumn: "created_at", +}; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +function thresholdAnalysis(measures = [FAILURES]) { + return analysis({ + modes: [ + { mode: "threshold", shape: "scalar", measures, groupColumns: [], absenceAllowed: true }, + ], + }); +} + +const NAMES = { + "threshold:failures": "failures on runs", + "absence:failures": "No failures on runs", + "threshold:took": "took on runs", + "match:": "New runs rows", +}; + +let analyzed: QueryAlertAnalysis = thresholdAnalysis(); +const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => { + if (String(url) === ANALYZE_PATH) { + return new Response(JSON.stringify({ analysis: analyzed, names: NAMES })); + } + if (String(url) === "/resources/alerts/create") { + return new Response(JSON.stringify({ ok: true, alertId: "a1" })); + } + if (String(url) === PREVIEW_PATH) { + return new Response( + JSON.stringify({ + ok: true, + mode: "threshold", + value: 7, + wouldFire: true, + window: { minutes: 60, from: "", to: "" }, + }) + ); + } + return new Response("{}"); +}); + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +let container: HTMLElement; +let root: Root; + +async function mount( + prefill: Partial = {}, + chat?: ComponentProps["chat"] +) { + await act(async () => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement(QueryAlertTranscript, { + prefill: { query: QUERY, period: "1h", ...prefill }, + alertDetailPath: (id: string) => `/alerts/${id}`, + environmentTitle: "Production", + chat, + }) + ) + ) + ) + ); +} + +const card = () => document.querySelector("[role=dialog]"); +const question = () => card()?.getAttribute("aria-label"); +const rows = () => + Array.from(card()!.querySelectorAll("li button")).map((b) => b.textContent); +const row = (label: string) => { + const found = Array.from(card()!.querySelectorAll("li button")).find( + (b) => b.textContent === label + ); + if (!found) throw new Error(`no ${label}: ${rows()}`); + return found; +}; +const choose = (label: string) => act(() => row(label).click()); +const button = (label: string) => + Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label + )!; +function type(el: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const input = (label: string) => + document.querySelector(`input[aria-label="${label}"]`)!; + +describe("QueryAlertTranscript", () => { + beforeEach(() => { + analyzed = thresholdAnalysis(); + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("analyzes on the server and opens on a read-only Query step", async () => { + await mount(); + const call = fetchMock.mock.calls.find(([url]) => url === ANALYZE_PATH)!; + expect(JSON.parse(call[1]!.body as string)).toEqual({ query: QUERY }); + expect(question()).toBe("Watch this query?"); + expect(card()!.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(card()!.querySelector("input, textarea")).toBeNull(); + const block = card()!.querySelector("[data-testid=query-alert-query]")!; + const keywords = Array.from(block.querySelectorAll(".token.keyword")).map((t) => t.textContent); + expect(keywords).toEqual(expect.arrayContaining(["SELECT", "AS", "FROM"])); + expect(block.querySelector("button")).toBeNull(); + }); + + it("skips Signal with one choice and asks for it with two", async () => { + await mount(); + choose("Continue"); + expect(question()).toBe("When should it fire?"); + + act(() => root.unmount()); + root = createRoot(container); + analyzed = thresholdAnalysis([FAILURES, TOOK]); + await mount(); + choose("Continue"); + expect(question()).toBe("Which value should it watch?"); + expect(rows()).toEqual(["failures", "took"]); + }); + + it("has no Window step in match mode", async () => { + analyzed = analysis({ modes: [MATCH] }); + await mount(); + choose("Continue"); + expect(rows()).toEqual(["New matching results appear"]); + choose("New matching results appear"); + expect(question()).toBe("How long should it stay active?"); + }); + + it("shows the analyzer messages and no steps when nothing is supported", async () => { + analyzed = analysis({ + ok: false, + unsupported: [ + { mode: "match", code: "limit", message: "Remove LIMIT to watch new rows." }, + { mode: "threshold", code: "no_measure", message: "Threshold alerts need a number." }, + ], + }); + await mount(); + expect(card()).toBeNull(); + const unavailable = document.querySelector("[data-testid=query-alert-unavailable]")!; + expect(unavailable.textContent).toContain("Remove LIMIT to watch new rows."); + expect(unavailable.textContent).toContain("Threshold alerts need a number."); + }); + + it("reviews, previews and creates with the query and the signal choice", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") }); + try { + analyzed = thresholdAnalysis([FAILURES, TOOK]); + await mount(); + choose("Continue"); + choose("took"); + choose("Value crosses a threshold"); + type(input("Threshold"), "5"); + choose("Below"); + expect(question()).toBe("Over what window?"); + expect(input("Window").value).toBe("60"); + choose("Continue"); + choose("For 24 hours"); + choose("Ops webhook"); + act(() => button("Continue").click()); + expect(question()).toBe("What should it be called?"); + expect(input("Name").value).toBe("took on runs"); + choose("Continue"); + + expect(card()).toBeNull(); + const review = document.querySelector("[data-testid=query-alert-review]")!; + expect(review.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(review.textContent).toContain("took < 5 for 60m"); + expect(review.textContent).toContain("Watch"); + expect(review.textContent).toContain("Production · Custom query · Recurring"); + expect(review.textContent).toContain("Notify: Ops webhook"); + const eye = renderToStaticMarkup(createElement(EyeIcon)).match(/ d="([^"]+)"/)![1]; + expect(review.querySelector("svg path")!.getAttribute("d")).toBe(eye); + expect(Array.from(review.querySelectorAll("button")).map((b) => b.textContent)).toEqual([ + "Create alert", + "Preview", + "Customize", + "Start over", + ]); + + await act(async () => button("Preview").click()); + const preview = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse(preview[1]!.body as string)).toEqual({ + query: QUERY, + mode: "threshold", + measure: "took", + windowMinutes: 60, + threshold: 5, + operator: "<", + }); + expect(review.textContent).toContain("Current value: 7"); + + await act(async () => button("Create alert").click()); + const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create"); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]![1]!.body as string)).toEqual({ + organizationId: "org1", + projectId: "proj1", + environmentId: "env1", + name: "took on runs", + expiry: "24h", + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind: "threshold", + operator: "<", + value: 5, + windowMinutes: 60, + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "took" }, + expiresAt: "2026-01-02T00:00:00.000Z", + notifierChannelIds: ["ch1"], + }, + }); + expect(document.querySelector("[data-testid=query-alert-review]")).toBeNull(); + const created = document.querySelector("[data-testid=alert-created]")!; + expect(created.textContent).toContain("took on runs is active."); + expect(created.textContent).toContain("It expires in 24h."); + expect(created.textContent).toContain("took < 5 for 60m · Production · Custom query"); + expect(created.textContent).toContain("Notifying: Ops webhook."); + expect(created.textContent).toContain("It's at the top of your alerts list."); + const view = Array.from(created.querySelectorAll("a")).find( + (a) => a.textContent === "View alert" + )!; + expect(view.getAttribute("href")).toBe("/alerts/a1"); + } finally { + vi.useRealTimers(); + } + }); + + async function reachReview() { + choose("Continue"); + choose("Value crosses a threshold"); + type(input("Threshold"), "5"); + choose("Above"); + choose("Continue"); + choose("For 24 hours"); + } + + it("offers This chat in the agent, preselected, and sends the chat ids", async () => { + const release = vi.fn(); + await mount({}, { chatId: "chat_on_use", ensure: async () => ({ chatId: "chat_1", release }) }); + reachReview(); + expect(rows()).toContain("This chat"); + act(() => button("Continue").click()); + choose("Continue"); + await act(async () => button("Create alert").click()); + const call = fetchMock.mock.calls.find(([url]) => url === "/resources/alerts/create")!; + expect(JSON.parse(call[1]!.body as string)).toMatchObject({ + chatNotifier: "on", + chatId: "chat_1", + transcriptChatId: "chat_1", + form: { notifierChannelIds: [] }, + }); + expect(release).not.toHaveBeenCalled(); + }); + + it("keeps transcriptChatId when only a channel is chosen", async () => { + await mount({}, { chatId: "chat_on_use", ensure: async () => ({ chatId: "chat_1" }) }); + reachReview(); + choose("This chat"); + choose("Ops webhook"); + act(() => button("Continue").click()); + choose("Continue"); + await act(async () => button("Create alert").click()); + const call = fetchMock.mock.calls.find(([url]) => url === "/resources/alerts/create")!; + const body = JSON.parse(call[1]!.body as string); + expect(body).toMatchObject({ + transcriptChatId: "chat_1", + form: { notifierChannelIds: ["ch1"] }, + }); + expect(body.chatNotifier).toBeUndefined(); + expect(body.chatId).toBeUndefined(); + }); + + it("offers no This chat in the manual dock", async () => { + await mount(); + reachReview(); + expect(rows()).not.toContain("This chat"); + }); +}); diff --git a/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx b/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx new file mode 100644 index 00000000000..9e71a8b2afd --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx @@ -0,0 +1,477 @@ +import { type ReactNode, useEffect, useState } from "react"; +import { AlertChannelsDialog } from "~/components/alerts/AlertChannelsDialog"; +import { alertCreatedBlock, REVIEW_MESSAGE } from "~/components/alerts/alertCreatedCard"; +import { AlertCreatedCard, WatchStyleReviewCard } from "~/components/alerts/creationCards"; +import { NewChatButton } from "~/components/alerts/creationControls"; +import { + QUERY_CONDITION_LABELS, + queryConditionModes, + querySignalOptions, + UNUSABLE_QUERY, + watchBlockedReasons, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import { QueryBlock } from "~/components/alerts/QueryBlock"; +import { QueryPreviewResult } from "~/components/alerts/QueryPreviewResult"; +import { useQueryAlertDraft, type QueryAlertChat } from "~/components/alerts/useQueryAlertDraft"; +import { CONTINUE_OPTION, NO_NOTIFIER } from "~/components/alerts/wizardSteps"; +import { notifyLabel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; +import { + CUSTOM_EXPIRY, + expiryLabel, + isExpiryTimestamp, + type DraftPrefillParams, +} from "~/components/dashboard-agent/alert-creation/flow-state"; +import { ChatText, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { ChatWizardCard, type ChatWizardStep } from "~/components/dashboard-agent/ChatWizardCard"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; + +export type QueryWatchPrefill = { + query: string; + period?: string; + mode?: QueryAlertMode; + measure?: string; + operator?: ">" | "<"; +}; + +export function queryWatchPrefill( + prefill: DraftPrefillParams | undefined +): QueryWatchPrefill | undefined { + if (prefill?.source !== "query" || !prefill.query) return undefined; + const { query, period, mode, measure } = prefill; + return { query, period, mode, measure }; +} + +type Analyzed = Pick; + +const DEFAULT_PERIOD = "7d"; +const QUERY_SCOPE_LABEL = "Custom query"; +const NEEDS_THRESHOLD = "Enter a threshold first"; +const OPERATORS = [ + { id: ">", label: "Above" }, + { id: "<", label: "Below" }, +]; + +export function QueryAlertTranscript({ + prefill, + alertDetailPath, + onNewChat, + onCreated, + environmentTitle, + chat, +}: { + prefill: QueryWatchPrefill; + alertDetailPath: (alertId: string) => string; + onNewChat?: () => void; + onCreated?: (info: { chatId?: string; name: string }) => void; + environmentTitle?: string; + chat?: QueryAlertChat; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const analyzePath = `/resources${v3EnvironmentPath(organization, project, environment)}/alerts/analyze-query`; + const [analyzed, setAnalyzed] = useState(); + + useEffect(() => { + let current = true; + fetch(analyzePath, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: prefill.query }), + }) + .then(async (resp) => (resp.ok ? ((await resp.json()) as Analyzed) : "error")) + .catch(() => "error" as const) + .then((result) => { + if (current) setAnalyzed(result); + }); + return () => { + current = false; + }; + }, [analyzePath, prefill.query]); + + const full: QueryAlertPrefill | undefined = + analyzed && analyzed !== "error" + ? { + query: prefill.query, + period: prefill.period ?? DEFAULT_PERIOD, + mode: prefill.mode, + measure: prefill.measure, + operator: prefill.operator, + ...analyzed, + } + : undefined; + const blocked = full ? watchBlockedReasons(full.analysis, full.query) : []; + const reasons = + blocked.length || !full || querySignalOptions(full.analysis).length + ? blocked + : [UNUSABLE_QUERY]; + + const header = ( + + + + ); + + if (!analyzed) { + return ( + +
+ {header} + + + +
+
+ ); + } + + if (!full || reasons.length) { + return ( + +
+ {header} + +
+ + + {(full ? reasons : [UNUSABLE_QUERY]).map((reason) => ( +

{reason}

+ ))} +
+
+
+
+
+ ); + } + + return ( + + ); +} + +function stepIds(signalCount: number, mode: QueryAlertMode): string[] { + return [ + "query", + ...(signalCount > 1 ? ["signal"] : []), + "condition", + ...(mode === "threshold" ? ["threshold"] : []), + ...(mode === "match" ? [] : ["window"]), + "expiry", + "notifiers", + "name", + ]; +} + +function QueryAlertChat({ + prefill, + header, + alertDetailPath, + onNewChat, + onCreated, + environmentTitle, + chat, +}: { + prefill: QueryAlertPrefill; + header: ReactNode; + alertDetailPath: (alertId: string) => string; + onNewChat?: () => void; + onCreated?: (info: { chatId?: string; name: string }) => void; + environmentTitle?: string; + chat?: QueryAlertChat; +}) { + const [created, setCreated] = useState<{ id?: string }>(); + const draft = useQueryAlertDraft( + prefill, + (id, chatId) => { + setCreated({ id }); + onCreated?.({ chatId, name: draft.name }); + }, + chat + ); + const { analysis, signals, mode, flow, bounds } = draft; + const [stepId, setStepId] = useState("query"); + const [answered, setAnswered] = useState([]); + const [reviewing, setReviewing] = useState(false); + const [closed, setClosed] = useState(false); + + const ids = stepIds(signals.length, mode); + const done = (id: string) => answered.includes(id); + const advance = (from: string, nextMode: QueryAlertMode = mode) => { + setAnswered((prev) => (prev.includes(from) ? prev : [...prev, from])); + const order = stepIds(signals.length, nextMode); + const next = order[order.indexOf(from) + 1]; + if (next) setStepId(next); + else setReviewing(true); + }; + const startOver = () => { + setAnswered([]); + setStepId("query"); + setReviewing(false); + setClosed(false); + }; + + const continueOption = (disabledReason?: string) => [ + { id: CONTINUE_OPTION, label: "Continue", disabledReason }, + ]; + const stepsById: Record = { + query: { + id: "query", + question: "Watch this query?", + allowSkip: false, + content: , + options: continueOption(), + selected: done("query") ? [CONTINUE_OPTION] : [], + }, + signal: { + id: "signal", + question: "Which value should it watch?", + allowSkip: false, + options: signals.map((s) => ({ id: s.id, label: s.label })), + selected: done("signal") ? [draft.signal.id] : [], + }, + condition: { + id: "condition", + question: "When should it fire?", + allowSkip: false, + options: draft.modes.map((m) => ({ id: m, label: QUERY_CONDITION_LABELS[m] })), + selected: done("condition") ? [mode] : [], + }, + threshold: { + id: "threshold", + question: `Fire when ${draft.signal.measure ?? "the value"} is`, + allowSkip: false, + fields: [ + { + id: "value", + ariaLabel: "Threshold", + placeholder: "1000", + numeric: true, + live: true, + current: draft.valueText || undefined, + parse: (text) => (text.trim() && Number.isFinite(Number(text)) ? text.trim() : undefined), + onSubmit: draft.setValueText, + onClear: () => draft.setValueText(""), + }, + ], + options: OPERATORS.map((o) => ({ + ...o, + disabledReason: draft.valueValid ? undefined : NEEDS_THRESHOLD, + })), + selected: (done("threshold") || prefill.operator) && draft.valueValid ? [draft.operator] : [], + }, + window: { + id: "window", + question: "Over what window?", + description: `At least ${bounds.min} min, at most ${bounds.max} min.`, + allowSkip: false, + fields: [ + { + id: "window", + ariaLabel: "Window", + unit: "min", + numeric: true, + live: true, + current: draft.windowText, + rule: { min: bounds.min, max: bounds.max }, + parse: (text) => { + const n = Number(text.trim()); + return Number.isInteger(n) && n >= bounds.min && n <= bounds.max + ? String(n) + : undefined; + }, + onSubmit: draft.setWindowText, + }, + ], + options: continueOption(draft.windowValid ? undefined : "Enter a valid window"), + selected: done("window") && draft.windowValid ? [CONTINUE_OPTION] : [], + }, + expiry: draft.expiryStep, + notifiers: { + ...draft.notifiersStep, + confirmLabel: "Continue", + confirmDisabled: !draft.hasNotifier || flow.answers.expiry === undefined, + error: draft.hasNotifier ? undefined : NO_NOTIFIER, + }, + name: { + id: "name", + question: "What should it be called?", + allowSkip: false, + fields: [ + { + id: "name", + ariaLabel: "Name", + live: true, + current: draft.name, + parse: (text) => text.trim() || undefined, + onSubmit: draft.setEditedName, + }, + ], + options: continueOption(draft.name.trim() ? undefined : "Enter a name"), + selected: done("name") ? [CONTINUE_OPTION] : [], + }, + }; + const steps = ids.map((id) => stepsById[id]!); + const stepIndex = Math.max(0, ids.indexOf(stepId)); + + function select(id: string, value: string) { + switch (id) { + case "signal": { + draft.setSignalId(value); + const signal = signals.find((s) => s.id === value); + const modes = queryConditionModes(analysis, signal); + advance(id, modes.includes(mode) ? mode : modes[0]); + return; + } + case "condition": + draft.setModeChoice(value as QueryAlertMode); + advance(id, value as QueryAlertMode); + return; + case "threshold": + if (!draft.valueValid) return; + draft.setOperator(value as ">" | "<"); + advance(id); + return; + case "expiry": + draft.selectStep(id, value); + if (value !== CUSTOM_EXPIRY) advance(id); + return; + case "notifiers": + draft.selectStep(id, value); + return; + default: + advance(id); + } + } + + const summary = + mode === "threshold" + ? `${draft.measure} ${draft.operator} ${draft.valueText} for ${draft.windowMinutes}m` + : mode === "absence" + ? `No matching results for ${draft.windowMinutes}m` + : QUERY_CONDITION_LABELS.match; + const expiry = flow.answers.expiry; + const createdBlock = created + ? alertCreatedBlock(created.id ?? "", { + name: draft.name, + conditionSummary: summary, + scopeLabel: QUERY_SCOPE_LABEL, + environmentTitle, + behavior: "recurring", + notifyLabel: notifyLabel(flow.answers, draft.channels), + ...(isExpiryTimestamp(expiry) + ? { expiresAt: new Date(expiry!).toISOString() } + : { expiryLabel: expiryLabel(expiry) }), + }) + : undefined; + + return ( + <> + +
+ {header} + {reviewing && !created ? ( + +
+ void draft.create(), + submitting: draft.submitting, + createDisabled: !draft.canCreate, + onPreview: () => void draft.runPreview(), + previewDisabled: !draft.canPreview || draft.previewing, + onCustomize: () => { + setReviewing(false); + setClosed(false); + }, + onStartOver: onNewChat ?? startOver, + }} + detail={} + /> + {draft.preview ? ( + + ) : null} + {draft.createError ? {draft.createError} : null} +
+
+ ) : null} + {created && createdBlock ? ( + + + + + ) : null} + {closed && !reviewing && !created ? ( + + + + ) : null} +
+
+ {draft.addingChannel ? ( + { + if (!flow.answers.notifiers?.includes(channel.id)) flow.toggleNotifier(channel.id); + }} + onClose={() => { + draft.setAddingChannel(false); + draft.addChannel.onCreated(); + }} + /> + ) : null} +
+ {!closed && !reviewing && !created ? ( +
+ setStepId(steps[index]?.id ?? "query")} + onSelect={select} + onSkip={() => {}} + onClose={() => setClosed(true)} + onConfirm={(id) => advance(id)} + /> +
+ ) : null} + {onNewChat ? ( +
+ +
+ ) : null} +
+ + ); +} diff --git a/apps/webapp/app/components/alerts/QueryBlock.tsx b/apps/webapp/app/components/alerts/QueryBlock.tsx new file mode 100644 index 00000000000..a570874a1e4 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryBlock.tsx @@ -0,0 +1,17 @@ +import { CodeBlock } from "~/components/code/CodeBlock"; + +export function QueryBlock({ query }: { query: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/components/alerts/QueryPreviewResult.tsx b/apps/webapp/app/components/alerts/QueryPreviewResult.tsx new file mode 100644 index 00000000000..2422d972ce3 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryPreviewResult.tsx @@ -0,0 +1,74 @@ +import type { PreviewQueryResponse } from "~/v3/services/alerts/previewQuery.server"; + +export type QueryPreviewOutcome = + | { kind: "result"; response: PreviewQueryResponse } + | { kind: "busy" } + | { kind: "error"; message: string }; + +function formatCell(value: unknown): string { + if (value === null || value === undefined) return ""; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +export function QueryPreviewResult({ + outcome, + detailRows, +}: { + outcome: QueryPreviewOutcome; + detailRows: number; +}) { + if (outcome.kind === "busy") { + return

Another query is running, try again

; + } + if (outcome.kind === "error") { + return

{outcome.message}

; + } + const response = outcome.response; + if (!response.ok) { + return

{response.reason}

; + } + const rows = response.rows ?? []; + const count = response.total ?? rows.length; + return ( +
+ {response.mode === "threshold" ? ( + <> +

Current value: {response.value ?? "–"}

+ {response.offenders?.length ? ( +
    + {response.offenders.map((offender) => ( +
  • + {offender.key} + {offender.value} +
  • + ))} +
+ ) : null} + + ) : response.mode === "match" ? ( + <> +

+ {count} + {response.capped ? "+" : ""} new matching rows in the window +

+ {rows.length ? ( +
    + {rows.slice(0, detailRows).map((row) => ( +
  • + {Object.entries(row.columns) + .map(([column, value]) => `${column}: ${formatCell(value)}`) + .join(", ")} +
  • + ))} +
+ ) : null} + + ) : ( +

Matching rows in the window: {response.value ?? count}

+ )} +

+ {response.wouldFire ? "Would fire now" : "Would not fire now"} +

+
+ ); +} diff --git a/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts b/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts new file mode 100644 index 00000000000..857949da752 --- /dev/null +++ b/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import type * as RemixReactModule from "@remix-run/react"; +import { createElement, type ComponentProps, type ReactNode } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { DashboardAgentProvider } from "~/components/dashboard-agent/dashboardAgentLauncher"; +import { decodeQueryParam } from "./queryAlertLink"; +import { WatchQueryButton } from "./WatchQueryButton"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), +})); + +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +const THRESHOLD = analysis({ + modes: [ + { + mode: "threshold", + shape: "scalar", + measures: [{ column: "failures", type: "UInt64", countLike: true }], + groupColumns: [], + absenceAllowed: true, + }, + ], +}); + +let container: HTMLElement; +let root: Root; + +function render( + props: ComponentProps, + agent?: ComponentProps["value"] +) { + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement( + DashboardAgentProvider, + { value: agent ?? null }, + createElement(WatchQueryButton, props) + ) + ) + ) + ) + ); +} + +describe("WatchQueryButton", () => { + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("renders nothing before the query has been run", () => { + render({ alertsPath: "/alerts", period: "7d", watch: undefined }); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when the analysis is ok but has no modes", () => { + render({ + alertsPath: "/alerts", + period: "7d", + watch: { query: "SELECT 1", analysis: analysis({}) }, + }); + expect(container.textContent).toBe(""); + }); + + it("links to the alerts page with the query as base64url (catches dropped encoding)", () => { + const query = "SELECT count() AS failures FROM runs WHERE status = 'Failed' -- ü?/+"; + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }); + const href = container.querySelector("a")!.getAttribute("href")!; + expect(container.querySelector("a")!.textContent).toBe("Watch"); + expect(container.querySelector("[data-testid=watch-query] a")).not.toBeNull(); + expect(container.querySelector("a svg")).not.toBeNull(); + const params = new URL(href, "http://localhost").searchParams; + expect(params.get("create")).toBe("1"); + expect(params.get("source")).toBe("query"); + expect(params.get("qperiod")).toBe("1h"); + expect(params.has("period")).toBe(false); + expect(params.get("mode")).toBe("threshold"); + expect(params.get("measure")).toBe("failures"); + const q = params.get("q")!; + expect(q).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeQueryParam(q)).toBe(query); + }); + + it("opens the agent with the query prefill instead of navigating when the agent is on", () => { + const openCreateAlert = vi.fn(); + const query = "SELECT count() AS failures FROM runs"; + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }, { + openCreateAlert, + } as unknown as ComponentProps["value"]); + expect(container.querySelector("a")).toBeNull(); + const button = container.querySelector("[data-testid=watch-query] button")!; + expect(button.textContent).toBe("Watch"); + act(() => button.click()); + expect(openCreateAlert).toHaveBeenCalledWith({ + source: "query", + query, + period: "1h", + mode: "threshold", + measure: "failures", + }); + }); + + it("does not submit the surrounding query form when opening the agent", () => { + const openCreateAlert = vi.fn(); + const onSubmit = vi.fn((e: Event) => e.preventDefault()); + const form = document.createElement("form"); + form.addEventListener("submit", onSubmit); + document.body.appendChild(form); + form.appendChild(container); + render( + { + alertsPath: "/alerts", + period: "1h", + watch: { query: "SELECT count() AS failures FROM runs", analysis: THRESHOLD }, + }, + { openCreateAlert } as unknown as ComponentProps["value"] + ); + act(() => form.querySelector("button")!.click()); + expect(openCreateAlert).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + form.remove(); + }); + + it("renders nothing when the encoded query exceeds 8 KB even though it has fewer characters", () => { + const query = `SELECT count() AS failures FROM runs -- ${"é".repeat(3500)}`; + expect(query.length).toBeLessThan(8192); + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when the analysis failed", () => { + render({ alertsPath: "/alerts", period: "1h", watch: { query: "SELECT 1", analysis: null } }); + expect(container.textContent).toBe(""); + }); +}); diff --git a/apps/webapp/app/components/alerts/WatchQueryButton.tsx b/apps/webapp/app/components/alerts/WatchQueryButton.tsx new file mode 100644 index 00000000000..4b636b77917 --- /dev/null +++ b/apps/webapp/app/components/alerts/WatchQueryButton.tsx @@ -0,0 +1,58 @@ +import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { + watchBlockedReasons, + watchQueryChoice, + watchQueryHref, +} from "~/components/alerts/queryAlertLink"; +import { useDashboardAgent } from "~/components/dashboard-agent/dashboardAgentLauncher"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; + +export type WatchQueryAnalysis = { query: string; analysis: QueryAlertAnalysis | null }; + +export function WatchQueryButton({ + alertsPath, + period, + watch, +}: { + alertsPath: string; + period: string; + watch: WatchQueryAnalysis | null | undefined; +}) { + const agent = useDashboardAgent(); + const analysis = watch?.analysis; + if (!watch || !analysis?.ok || watchBlockedReasons(analysis, watch.query).length > 0) { + return null; + } + return ( + + {agent ? ( + + ) : ( + + Watch + + )} + + ); +} diff --git a/apps/webapp/app/components/alerts/alertsListFilters.ts b/apps/webapp/app/components/alerts/alertsListFilters.ts index 35c00c2a7bd..e92124fdc18 100644 --- a/apps/webapp/app/components/alerts/alertsListFilters.ts +++ b/apps/webapp/app/components/alerts/alertsListFilters.ts @@ -29,7 +29,7 @@ export const MAX_LIST_ALERTS = 500; export const DEFAULT_PERIOD = "7d"; const ALL_PERIODS = "all"; const STATUS_VALUES = ["needs_attention", "active", "inactive"]; -const AREA_VALUES = ["queues", "errors", "runs", "health", "unknown"]; +const AREA_VALUES = ["queues", "errors", "runs", "health", "query", "unknown"]; function splitList(value: string | null, allowed: string[]): string[] { if (!value) return []; diff --git a/apps/webapp/app/components/alerts/createAlertRequest.ts b/apps/webapp/app/components/alerts/createAlertRequest.ts index b65ff29eaaf..2b93bbe76e8 100644 --- a/apps/webapp/app/components/alerts/createAlertRequest.ts +++ b/apps/webapp/app/components/alerts/createAlertRequest.ts @@ -66,3 +66,53 @@ export function createAlertRequestBody( }, }; } + +export type QueryAlertDraft = { + query: string; + name: string; + mode: "threshold" | "absence" | "match"; + measure?: string; + operator: ">" | "<"; + value?: number; + windowMinutes: number; + expiry?: string; + notifiers: string[]; +}; + +export type QueryAlertChatRequest = { chatId: string; transcriptChatId: string }; + +export function queryAlertRequestBody( + ids: { organizationId: string; projectId: string; environmentId: string }, + draft: QueryAlertDraft, + chat?: QueryAlertChatRequest +) { + const wantsChat = Boolean(chat) && draft.notifiers.includes("chat"); + const conditionKind = + draft.mode === "match" ? "occurrence" : draft.mode === "absence" ? "absence" : "threshold"; + return { + organizationId: ids.organizationId, + projectId: ids.projectId, + environmentId: ids.environmentId, + name: draft.name, + chatNotifier: wantsChat ? ("on" as const) : undefined, + chatId: wantsChat ? chat!.chatId : undefined, + transcriptChatId: chat?.transcriptChatId, + expiry: draft.expiry && EXPIRY_TOKEN_PATTERN.test(draft.expiry) ? draft.expiry : undefined, + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind, + operator: draft.mode === "threshold" ? draft.operator : undefined, + value: draft.mode === "threshold" ? draft.value : undefined, + windowMinutes: draft.mode === "threshold" ? draft.windowMinutes : undefined, + quietWindowMinutes: draft.mode === "absence" ? draft.windowMinutes : undefined, + query: draft.query, + querySignalChoice: { + mode: draft.mode, + measure: draft.mode === "match" ? undefined : draft.measure, + }, + expiresAt: expiryToIso(draft.expiry), + notifierChannelIds: draft.notifiers.filter((n) => n !== "chat"), + }, + }; +} diff --git a/apps/webapp/app/components/alerts/creationCards.tsx b/apps/webapp/app/components/alerts/creationCards.tsx index 9db137936e5..e8df630afe6 100644 --- a/apps/webapp/app/components/alerts/creationCards.tsx +++ b/apps/webapp/app/components/alerts/creationCards.tsx @@ -1,4 +1,5 @@ import { EyeIcon } from "@heroicons/react/20/solid"; +import type { ReactNode } from "react"; import { ChatActionsRow, ChatSystemBlock } from "~/components/dashboard-agent/chat-layout"; import type { AlertCreatedBlock } from "@internal/dashboard-agent-contracts"; import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock"; @@ -20,14 +21,19 @@ export type ReviewCardActions = { onStartOver: () => void; submitting?: boolean; blockedReason?: string; + createDisabled?: boolean; + onPreview?: () => void; + previewDisabled?: boolean; }; export function WatchStyleReviewCard({ fields, actions, + detail, }: { fields: ReviewCardFields; actions: ReviewCardActions; + detail?: ReactNode; }) { const scopeVal = fields.scopeValue ? ` · ${fields.scopeValue}` : ""; @@ -39,11 +45,22 @@ export function WatchStyleReviewCard({ + {actions.onPreview ? ( + + ) : null} @@ -54,6 +71,7 @@ export function WatchStyleReviewCard({ } >

{fields.name}

+ {detail}

{fields.conditionSummary}

{fields.environmentTitle ? `${fields.environmentTitle} · ` : null} diff --git a/apps/webapp/app/components/alerts/domainIcons.ts b/apps/webapp/app/components/alerts/domainIcons.ts index 43bb39dfc1a..b17ed052485 100644 --- a/apps/webapp/app/components/alerts/domainIcons.ts +++ b/apps/webapp/app/components/alerts/domainIcons.ts @@ -1,10 +1,11 @@ import { BugIcon } from "~/assets/icons/BugIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import type { RenderIcon } from "~/components/primitives/Icon"; -export type AlertArea = "queues" | "errors" | "health" | "runs"; +export type AlertArea = "queues" | "errors" | "health" | "runs" | "query"; export const WATCHING_TOOLTIP = "What this alert keeps an eye on: queues, runs, errors, or environment health. It decides which metrics you can pick and which scopes (queue, task, error group) are available."; @@ -14,6 +15,7 @@ export const AREA_LABEL: Record = { errors: "Errors", health: "Health", runs: "Runs", + query: "Custom", }; export const AREA_ICON: Record = { @@ -21,6 +23,7 @@ export const AREA_ICON: Record = { errors: BugIcon, health: RadarPulseIcon, runs: RunsIcon, + query: CodeSquareIcon, }; export function isAlertArea(value: string): value is AlertArea { diff --git a/apps/webapp/app/components/alerts/queryAlertLink.ts b/apps/webapp/app/components/alerts/queryAlertLink.ts new file mode 100644 index 00000000000..fe3470848dc --- /dev/null +++ b/apps/webapp/app/components/alerts/queryAlertLink.ts @@ -0,0 +1,133 @@ +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { parsePeriodToMs } from "~/utils/periods"; + +export const MAX_WATCH_QUERY_LENGTH = 8192; +const MAX_DEFAULT_WINDOW_MINUTES = 24 * 60; +export const UNUSABLE_QUERY = "This query can't be used for an alert."; + +export type QueryAlertMode = "threshold" | "absence" | "match"; + +export type QueryAlertPrefill = { + query: string; + period: string; + analysis: QueryAlertAnalysis; + names: Record; + mode?: QueryAlertMode; + measure?: string; + operator?: ">" | "<"; +}; + +export function queryAlertNameKey(mode: QueryAlertMode, measure?: string) { + return `${mode}:${measure ?? ""}`; +} + +export function encodeQueryParam(query: string): string { + let binary = ""; + for (const byte of new TextEncoder().encode(query)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function decodeQueryParam(value: string): string | undefined { + try { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4)); + return new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(binary, (char) => char.charCodeAt(0)) + ); + } catch { + return undefined; + } +} + +export function watchBlockedReasons(analysis: QueryAlertAnalysis, query: string): string[] { + if (encodeQueryParam(query).length > MAX_WATCH_QUERY_LENGTH) + return ["Query is too long to watch"]; + if (analysis.ok && analysis.modes.length > 0) return []; + const messages = [...analysis.errors, ...analysis.unsupported].map((issue) => issue.message); + const unique = [...new Set(messages)].slice(0, 2); + return unique.length ? unique : [UNUSABLE_QUERY]; +} + +export function watchQueryHref( + alertsPath: string, + query: string, + period: string, + analysis: QueryAlertAnalysis +): string { + const params = new URLSearchParams({ + create: "1", + source: "query", + q: encodeQueryParam(query), + qperiod: period, + }); + const choice = watchQueryChoice(analysis); + if (choice.mode) params.set("mode", choice.mode); + if (choice.measure) params.set("measure", choice.measure); + return `${alertsPath}?${params.toString()}`; +} + +export function watchQueryChoice(analysis: QueryAlertAnalysis): { + mode?: QueryAlertMode; + measure?: string; +} { + const [only] = analysis.modes; + if (analysis.modes.length !== 1 || !only) return {}; + if (only.mode === "threshold" && only.measures.length === 1 && only.measures[0]) { + return { mode: only.mode, measure: only.measures[0].column }; + } + return { mode: only.mode }; +} + +export function queryWindowBounds(limits: QueryAlertAnalysis["limits"]) { + return { + min: Math.max(1, Math.ceil(limits.minCadenceSec / 60)), + max: Math.floor(limits.maxWindowSec / 60), + }; +} + +export function defaultQueryWindowMinutes( + period: string, + limits: QueryAlertAnalysis["limits"] +): number { + const { min, max } = queryWindowBounds(limits); + const periodMinutes = Math.floor(parsePeriodToMs(period) / 60_000); + return Math.min(Math.max(Math.min(periodMinutes, MAX_DEFAULT_WINDOW_MINUTES), min), max); +} + +export type QuerySignalOption = { + id: string; + label: string; + mode: "threshold" | "match"; + measure?: string; +}; + +export function querySignalOptions(analysis: QueryAlertAnalysis): QuerySignalOption[] { + return analysis.modes.flatMap((mode): QuerySignalOption[] => + mode.mode === "threshold" + ? mode.measures.map((measure) => ({ + id: `threshold:${measure.column}`, + label: measure.column, + mode: "threshold", + measure: measure.column, + })) + : [{ id: "match", label: `New ${mode.sourceId} rows`, mode: "match" }] + ); +} + +export const QUERY_CONDITION_LABELS: Record = { + threshold: "Value crosses a threshold", + match: "New matching results appear", + absence: "No matching results for the window", +}; + +export function queryConditionModes( + analysis: QueryAlertAnalysis, + signal: QuerySignalOption | undefined +): QueryAlertMode[] { + if (!signal) return []; + if (signal.mode === "match") return ["match"]; + const threshold = analysis.modes.find((mode) => mode.mode === "threshold"); + if (threshold?.mode !== "threshold") return ["threshold"]; + const measure = threshold.measures.find((m) => m.column === signal.measure); + return threshold.absenceAllowed && measure?.countLike ? ["threshold", "absence"] : ["threshold"]; +} diff --git a/apps/webapp/app/components/alerts/queryGroupSelection.ts b/apps/webapp/app/components/alerts/queryGroupSelection.ts new file mode 100644 index 00000000000..2ff19dc89e1 --- /dev/null +++ b/apps/webapp/app/components/alerts/queryGroupSelection.ts @@ -0,0 +1,34 @@ +import type { MetricSeriesPoint } from "./alertSeriesTypes"; + +export const MAX_VISIBLE_GROUPS = 8; + +// Groups that crossed the threshold have the best values toward it, so they sort first. +export function defaultGroupSelection({ + groups, + seriesMap, + operator, + range, + max = MAX_VISIBLE_GROUPS, +}: { + groups: string[]; + seriesMap: Map; + operator?: ">" | "<"; + range?: { since: number; until: number }; + max?: number; +}): string[] { + const below = operator === "<"; + const scored = groups.map((group, index) => { + let best: number | null = null; + for (const p of seriesMap.get(group) ?? []) { + if (p.value === null) continue; + if (range && (p.bucket < range.since || p.bucket > range.until)) continue; + if (best === null || (below ? p.value < best : p.value > best)) best = p.value; + } + return { group, index, best }; + }); + const toward = (best: number | null) => (best === null ? -Infinity : below ? -best : best); + return scored + .sort((a, b) => toward(b.best) - toward(a.best) || a.index - b.index) + .slice(0, max) + .map((s) => s.group); +} diff --git a/apps/webapp/app/components/alerts/useQueryAlertDraft.ts b/apps/webapp/app/components/alerts/useQueryAlertDraft.ts new file mode 100644 index 00000000000..6fc3e3ba899 --- /dev/null +++ b/apps/webapp/app/components/alerts/useQueryAlertDraft.ts @@ -0,0 +1,245 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { alertChannelsResourcePath } from "~/components/alerts/alertChannelsResourcePath"; +import { CREATE_ALERT_PATH, queryAlertRequestBody } from "~/components/alerts/createAlertRequest"; +import { + defaultQueryWindowMinutes, + queryAlertNameKey, + queryConditionModes, + querySignalOptions, + queryWindowBounds, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import type { QueryPreviewOutcome } from "~/components/alerts/QueryPreviewResult"; +import { allSteps, applyStepSelection } from "~/components/alerts/wizardSteps"; +import { useCreationFlowState } from "~/components/dashboard-agent/alert-creation"; +import type { AlertChannel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; +import type { PreviewQueryResponse } from "~/v3/services/alerts/previewQuery.server"; + +export type QueryAlertChat = { + chatId: string; + ensure: () => Promise<{ chatId: string; release?: () => void } | undefined>; +}; + +const PREVIEW_FAILED = "Preview failed. Try again."; +const CHAT_FAILED = "We couldn't set up this chat to receive the alert. Try again."; +const CREATE_FAILED = "Failed to create the alert. Try again."; + +export function useQueryAlertDraft( + prefill: QueryAlertPrefill, + onCreated: (alertId: string | undefined, chatId?: string) => void, + chat?: QueryAlertChat +) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { analysis, query } = prefill; + + const signals = querySignalOptions(analysis); + const [signalId, setSignalId] = useState( + () => + signals.find((s) => + prefill.mode === "match" + ? s.mode === "match" + : s.mode === "threshold" && s.measure === prefill.measure + )?.id ?? signals[0]!.id + ); + const signal = signals.find((s) => s.id === signalId) ?? signals[0]!; + const modes = queryConditionModes(analysis, signal); + const [modeChoice, setModeChoice] = useState(prefill.mode); + const mode = modeChoice && modes.includes(modeChoice) ? modeChoice : modes[0]!; + const measure = mode === "match" ? undefined : signal.measure; + + const [operator, setOperator] = useState<">" | "<">(prefill.operator ?? ">"); + const [valueText, setValueText] = useState(""); + const value = valueText.trim() === "" ? undefined : Number(valueText); + const valueValid = mode !== "threshold" || (value !== undefined && Number.isFinite(value)); + + const bounds = queryWindowBounds(analysis.limits); + const defaultWindow = defaultQueryWindowMinutes(prefill.period, analysis.limits); + const [windowText, setWindowText] = useState(() => String(defaultWindow)); + const windowMinutes = mode === "match" ? defaultWindow : Number(windowText); + const windowValid = + mode === "match" || + (Number.isInteger(windowMinutes) && windowMinutes >= bounds.min && windowMinutes <= bounds.max); + + const suggestedName = prefill.names[queryAlertNameKey(mode, measure)] ?? analysis.suggestedName; + const [editedName, setEditedName] = useState(); + const name = editedName ?? suggestedName; + + const flow = useCreationFlowState(undefined, { notifiers: chat ? ["chat"] : [] }); + const [addingChannel, setAddingChannel] = useState(false); + const channelsFetcher = useFetcher<{ + channels: AlertChannel[]; + canWrite?: boolean; + limits?: { used: number; limit: number }; + }>(); + const channelsPath = alertChannelsResourcePath(project.id, false, environment.id); + useEffect(() => { + channelsFetcher.load(channelsPath); + }, [channelsPath]); // eslint-disable-line react-hooks/exhaustive-deps -- fetcher is stable + const limits = channelsFetcher.data?.limits; + const addChannel = { + projectId: project.id, + environmentId: environment.id, + canWrite: channelsFetcher.data?.canWrite ?? false, + atLimit: limits ? limits.used >= limits.limit : false, + onCreated: () => channelsFetcher.load(channelsPath), + }; + const channels = channelsFetcher.data?.channels ?? []; + const steps = allSteps(flow, channels, chat?.chatId, addChannel); + const expiryStep = steps.find((step) => step.id === "expiry")!; + const notifiersStep = steps.find((step) => step.id === "notifiers")!; + const hasNotifier = notifiersStep.options.some( + (option) => !option.disabledReason && flow.answers.notifiers?.includes(option.id) + ); + const selectStep = (stepId: string, optionId: string) => + applyStepSelection(flow, stepId, optionId, { + onShowAll: () => {}, + onAddChannel: () => setAddingChannel(true), + }); + + const previewBody = { + query, + mode, + measure, + windowMinutes, + threshold: mode === "threshold" ? value : undefined, + operator: mode === "threshold" ? operator : undefined, + }; + const previewKey = JSON.stringify(previewBody); + const [preview, setPreview] = useState<{ key: string; outcome: QueryPreviewOutcome }>(); + const [previewing, setPreviewing] = useState(false); + const canPreview = windowValid && valueValid; + + const [submitting, setSubmitting] = useState(false); + const [createError, setCreateError] = useState(); + const canCreate = + canPreview && hasNotifier && flow.answers.expiry !== undefined && name.trim() !== ""; + + async function runPreview() { + setPreviewing(true); + let outcome: QueryPreviewOutcome; + try { + const resp = await fetch( + `/resources${v3EnvironmentPath(organization, project, environment)}/alerts/preview-query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: previewKey, + } + ); + outcome = + resp.status === 429 + ? { kind: "busy" } + : resp.ok + ? { kind: "result", response: (await resp.json()) as PreviewQueryResponse } + : { kind: "error", message: PREVIEW_FAILED }; + } catch { + outcome = { kind: "error", message: PREVIEW_FAILED }; + } + setPreview({ key: previewKey, outcome }); + setPreviewing(false); + } + + async function create() { + setSubmitting(true); + setCreateError(undefined); + let session: Awaited>; + try { + session = await chat?.ensure(); + if (chat && !session && flow.answers.notifiers?.includes("chat")) { + setCreateError(CHAT_FAILED); + setSubmitting(false); + return; + } + const resp = await fetch(CREATE_ALERT_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + queryAlertRequestBody( + { + organizationId: organization.id, + projectId: project.id, + environmentId: environment.id, + }, + { + query, + name: name.trim(), + mode, + measure, + operator, + value, + windowMinutes, + expiry: flow.answers.expiry, + notifiers: flow.answers.notifiers ?? [], + }, + session && { chatId: session.chatId, transcriptChatId: session.chatId } + ) + ), + }); + const data = (await resp.json().catch(() => ({}))) as { alertId?: string; error?: string }; + if (!resp.ok) { + setCreateError( + (resp.status === 400 || resp.status === 422) && data.error ? data.error : CREATE_FAILED + ); + setSubmitting(false); + session?.release?.(); + return; + } + if (session) onCreated(data.alertId, session.chatId); + else onCreated(data.alertId); + } catch { + session?.release?.(); + setCreateError(CREATE_FAILED); + setSubmitting(false); + } + } + + return { + analysis, + query, + signals, + signal, + setSignalId, + modes, + mode, + setModeChoice, + measure, + operator, + setOperator, + valueText, + setValueText, + value, + valueValid, + bounds, + windowText, + setWindowText, + windowMinutes, + windowValid, + name, + setEditedName, + flow, + channels, + addChannel, + addingChannel, + setAddingChannel, + expiryStep, + notifiersStep, + hasNotifier, + selectStep, + preview: preview && preview.key === previewKey ? preview.outcome : undefined, + previewing, + canPreview, + runPreview, + submitting, + createError, + canCreate, + create, + }; +} diff --git a/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx b/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx index 3a991e4dc1b..03a879c2f16 100644 --- a/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx @@ -74,6 +74,7 @@ export type ChatWizardStep = { question: string; description?: string; descriptionAction?: { label: string }; + content?: ReactNode; options: ChatWizardOption[]; allowSkip?: boolean; multiSelect?: boolean; @@ -843,6 +844,7 @@ export function ChatWizardCard({

) : null} + {step.content ?
{step.content}
: null}
    + openSource === "alerts" ? queryWatchPrefill(createAlertPrefill) : undefined + ); const [creationEntrySeq, setCreationEntrySeq] = useState(0); + const [queryWatchSeq, setQueryWatchSeq] = useState(0); + const queryWatchKey = `${creationEntrySeq}:${queryWatchSeq}`; + const [queryCreatedKey, setQueryCreatedKey] = useState(); + const queryCreatedSig = useRef(); + const setQueryCreatedSig = (sig: string) => { + queryCreatedSig.current = sig; + }; const [creationGeneration, setCreationGeneration] = useState(0); const creationAgentChat = useRef({ seq: -1 }); const [manualMode, setManualMode] = useState(Boolean(slackResume?.manual)); @@ -571,22 +582,33 @@ export function DashboardAgentPanel({ const prevOpenSourceRef = useRef(openSource); if (prevOpenSourceRef.current !== openSource || prevPrefillRef.current !== createAlertPrefill) { + const sameCreatedQuery = + openSource === "alerts" && + prevOpenSourceRef.current === openSource && + queryCreatedSig.current !== undefined && + queryCreatedSig.current === JSON.stringify(queryWatchPrefill(createAlertPrefill)); prevOpenSourceRef.current = openSource; prevPrefillRef.current = createAlertPrefill; - if (openSource === "alerts") { + if (sameCreatedQuery) { + // The created card stays; don't offer the same alert again. + } else if (openSource === "alerts") { + queryCreatedSig.current = undefined; setCreationMode(true); setManualMode(false); const draft = createAlertPrefill ? prefillToAnswers(createAlertPrefill) : draftFromSearch(location.search, true); setCreationDraft(draft); + setQueryWatch(queryWatchPrefill(createAlertPrefill)); prevCreateParamKeyRef.current = createSearchKey(location.search); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); creationAgentChat.current = { seq: -1 }; } else { + queryCreatedSig.current = undefined; setCreationMode(false); setCreationDraft(undefined); + setQueryWatch(undefined); prevCreateParamKeyRef.current = ""; } } else if ( @@ -597,6 +619,7 @@ export function DashboardAgentPanel({ prevCreateParamKeyRef.current = createParamKey; setManualMode(false); setCreationDraft(draftFromSearch(location.search, true)); + setQueryWatch(undefined); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); creationAgentChat.current = { seq: -1 }; @@ -774,6 +797,7 @@ export function DashboardAgentPanel({ newChat(); setCreationMode(true); setCreationDraft(draftFromSearch(location.search, true)); + setQueryWatch(undefined); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); }, [newChat, location.search]); @@ -1022,6 +1046,16 @@ export function DashboardAgentPanel({ ] ); + const ensureQueryChat = useCallback(async () => { + const seq = creationEntrySeqRef.current; + const reused = creationChatToReuse(creationAgentChat.current, creationEntrySeq, active?.chatId); + const chatId = reused ?? (await creationSession.ensure()); + if (!chatId) return undefined; + const release = + !reused && seq === creationEntrySeqRef.current ? creationSession.keep() : undefined; + return { chatId, release }; + }, [active?.chatId, creationEntrySeq, creationSession]); + const creationFlow = useCreationFlowState( async (answers: FlowAnswers) => { const { ok, chatId, release } = await handleAlertCreated(answers); @@ -1053,7 +1087,9 @@ export function DashboardAgentPanel({ navigate({ search: params.toString() }, { replace: true }); }, [slackResume, project.id, environment.id, setExpiry]); // eslint-disable-line react-hooks/exhaustive-deps -- once per resume - const creationTitle = creationFlow.created ? "Alert created" : "New alert"; + const queryCreated = queryCreatedKey === queryWatchKey; + const curatedTitle = creationFlow.created ? "Alert created" : "New alert"; + const creationTitle = queryWatch ? (queryCreated ? "Alert created" : "New alert") : curatedTitle; const headerTitle = inEditMode ? "Edit alert" : inCreationMode @@ -1071,6 +1107,10 @@ export function DashboardAgentPanel({ onPatchApplied: creationFlow.patchAnswers, resetKey: creationEntrySeq, resolveErrorGroup: errorGroupSource.resolve, + onQueryWatch: (watch) => { + setQueryWatch(watch); + setQueryWatchSeq((s) => s + 1); + }, }); const { handleDraftPatches, handleTextReply, handleError } = creationAgentSend; @@ -1216,28 +1256,54 @@ export function DashboardAgentPanel({ ) : null} {inCreationMode ? ( - v3AlertPath(organization, project, environment, id)} - onFreeText={sendCreationText} - systemLines={creationAgentSend.systemLines} - agentStreaming={thinkingChatId !== null && thinkingChatId === active?.chatId} - manual={manualMode} - onManualChange={setManualMode} - onNewChat={restartCreation} - density="compact" - addChannel={{ - ...addChannelAccess, - resumed: Boolean(slackResume) && creationEntrySeq === 0, - onResumeConsumed: () => setSlackResume(undefined), - }} - errorGroupSource={errorGroupSource} - environmentTitle={environmentFullTitle(environment)} - /> + {queryWatch ? ( + { + setQueryCreatedKey(queryWatchKey); + setQueryCreatedSig(JSON.stringify(queryWatch)); + revalidator.revalidate(); + if (chatId) { + const body = new FormData(); + body.set("intent", "rename"); + body.set("chatId", chatId); + body.set("title", name); + void fetch(actionPath, { method: "POST", body }).catch(() => {}); + } + }} + environmentTitle={environmentFullTitle(environment)} + alertDetailPath={(id) => v3AlertPath(organization, project, environment, id)} + onNewChat={restartCreation} + /> + ) : ( + v3AlertPath(organization, project, environment, id)} + onFreeText={sendCreationText} + systemLines={creationAgentSend.systemLines} + agentStreaming={thinkingChatId !== null && thinkingChatId === active?.chatId} + manual={manualMode} + onManualChange={setManualMode} + onNewChat={restartCreation} + density="compact" + addChannel={{ + ...addChannelAccess, + resumed: Boolean(slackResume) && creationEntrySeq === 0, + onResumeConsumed: () => setSlackResume(undefined), + }} + errorGroupSource={errorGroupSource} + environmentTitle={environmentFullTitle(environment)} + /> + )} ) : null} {loading ? ( diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx b/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx index b37e14ed926..e7ef33b170e 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx @@ -175,7 +175,7 @@ function ChannelCheckboxes({ ); } -function ManualStep({ +export function ManualStep({ step, onSelect, onConfirm, diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts index 5b92f8cc9c2..5e85b128bc4 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts @@ -255,12 +255,16 @@ describe("scope value on scope type change", () => { }); describe("below-threshold operator", () => { - it("rejects operator < and surfaces an error line", () => { + it("rejects operator < outside a query patch and surfaces an error line", () => { const patch = { condition: { operator: "<", value: 10 } }; expect(alertDraftPatchSchema.safeParse(patch).success).toBe(false); - expect(contractPatchSchema.safeParse(patch).success).toBe(false); expect(draftResultLines({ ok: true, patch: patch as never })[0]?.variant).toBe("error"); }); + + it("accepts operator < on a query patch", () => { + const patch = { area: "query", query: "SELECT 1", condition: { operator: "<" } }; + expect(alertDraftPatchSchema.safeParse(patch).success).toBe(true); + }); }); describe("unknown-key rejection", () => { diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts index 635827991ea..8f24aa78e21 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts @@ -1,6 +1,8 @@ import type { AlertCreationDraft } from "@internal/dashboard-agent-contracts"; import type { AlertScopeType } from "@internal/alert-contracts"; import { z } from "zod"; +import { MAX_WATCH_QUERY_LENGTH } from "~/components/alerts/queryAlertLink"; +import type { QueryWatchPrefill } from "~/components/alerts/QueryAlertTranscript"; import { encodeErrorGroupScope, errorFingerprint } from "~/utils/errorGroupScope"; import { needsScopeValue, resolveCapability, type FlowAnswers } from "./flow-state"; @@ -20,7 +22,7 @@ export const alertDraftPatchSchema = z condition: z .object({ kind: z.string().optional(), - operator: z.literal(">").optional(), + operator: z.enum([">", "<"]).optional(), value: z.number().optional(), unit: z.string().optional(), windowSec: z.number().optional(), @@ -42,12 +44,32 @@ export const alertDraftPatchSchema = z }) ) .optional(), + query: z.string().max(MAX_WATCH_QUERY_LENGTH).optional(), + querySignalChoice: z + .object({ + mode: z.enum(["threshold", "absence", "match"]), + measure: z.string().optional(), + }) + .optional(), reason: z.string().optional(), }) - .strict(); + .strict() + .refine((patch) => patch.condition?.operator !== "<" || patch.area === "query", { + message: "Below-threshold alerts aren't supported yet", + }); export type AlertDraftPatch = z.infer; +export function queryWatchFromPatch(patch: AlertDraftPatch): QueryWatchPrefill | undefined { + if (patch.area !== "query" || !patch.query?.trim()) return undefined; + return { + query: patch.query, + mode: patch.querySignalChoice?.mode, + measure: patch.querySignalChoice?.measure, + operator: patch.condition?.operator, + }; +} + /** The id to look up when an errorGroup patch didn't carry the task identifier. */ export function errorGroupToResolve(patch: AlertDraftPatch): string | undefined { const scope = patch.scope; diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts index 5b0d2871eba..35da3e024e6 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts @@ -1,6 +1,7 @@ import type { AlertArea, AlertCapability, CapabilityParam } from "@internal/alert-contracts"; import { listCapabilities } from "@internal/alert-contracts"; import { AREA_ICON, AREA_LABEL } from "~/components/alerts/domainIcons"; +import type { QueryAlertMode } from "~/components/alerts/queryAlertLink"; import type { RenderIcon } from "~/components/primitives/Icon"; import { formatNumber } from "~/utils/numberFormatter"; import { @@ -492,6 +493,11 @@ export type DraftPrefillParams = { scope?: string | ErrorGroupScope; scopeValue?: string; replace?: ReplaceTarget; + source?: "query"; + query?: string; + period?: string; + mode?: QueryAlertMode; + measure?: string; }; export function asArea(value: string | undefined): DraftPrefillParams["area"] { diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts new file mode 100644 index 00000000000..a064633f733 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import type * as RemixReactModule from "@remix-run/react"; +import { createElement, useEffect, useState, type ComponentProps } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + QueryAlertTranscript, + type QueryWatchPrefill, +} from "~/components/alerts/QueryAlertTranscript"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { ToolResultPayload } from "./draftPatch"; +import { useCreationAgentSend, type CreationAgentSend } from "./useCreationAgentSend"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + useFetcher: () => ({ data: undefined, load: () => {} }), +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); + +const QUERY = "SELECT count() AS failures FROM runs"; +const ANALYSIS = { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [ + { + mode: "threshold", + shape: "scalar", + measures: [{ column: "failures", type: "UInt64", countLike: true }], + groupColumns: [], + absenceAllowed: true, + }, + ], + unsupported: [], + errors: [], + limits: { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, + }, + suggestedName: "failures on runs", +}; + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const harness: { send?: CreationAgentSend } = {}; +let applied: unknown[] = []; + +function Harness({ onSend }: { onSend: (send: CreationAgentSend) => void }) { + const [watch, setWatch] = useState(); + const send = useCreationAgentSend({ + answers: {}, + onPatchApplied: (next) => applied.push(next), + onQueryWatch: setWatch, + }); + useEffect(() => onSend(send)); + return watch + ? createElement(QueryAlertTranscript, { prefill: watch, alertDetailPath: (id) => id }) + : createElement( + "div", + { "data-testid": "curated" }, + send.systemLines.map((line) => line.text).join("\n") + ); +} + +let container: HTMLElement; +let root: Root; + +describe("a query draft patch from the agent", () => { + beforeEach(() => { + applied = []; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ analysis: ANALYSIS, names: {} }))) + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement(Harness, { + onSend: (send) => { + harness.send = send; + }, + }) + ) + ) + ) + ); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("opens the query card instead of patching the curated draft", async () => { + const result: ToolResultPayload = { + ok: true, + patch: { area: "query", query: QUERY, querySignalChoice: { mode: "threshold" } } as never, + }; + await act(async () => harness.send!.handleDraftPatches([result])); + + expect(document.querySelector("[data-testid=curated]")).toBeNull(); + expect(applied).toEqual([]); + expect(document.querySelector("[data-testid=query-alert-flow]")).not.toBeNull(); + expect(document.querySelector("[role=dialog]")!.getAttribute("aria-label")).toBe( + "Watch this query?" + ); + expect(document.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + }); + + it("carries a below-threshold operator into the card", async () => { + const result: ToolResultPayload = { + ok: true, + patch: { + area: "query", + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "failures" }, + condition: { operator: "<" }, + } as never, + }; + await act(async () => harness.send!.handleDraftPatches([result])); + const row = (label: string) => + Array.from(document.querySelectorAll("[role=dialog] li button")).find( + (b) => b.textContent === label + )!; + act(() => row("Continue").click()); + act(() => row("Value crosses a threshold").click()); + const input = document.querySelector('input[aria-label="Threshold"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "3"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(row("Below").querySelector("[aria-label=Selected]")).not.toBeNull(); + expect(row("Above").querySelector("[aria-label=Selected]")).toBeNull(); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts index 50f6768798d..5a875981c8a 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts @@ -4,9 +4,11 @@ import { alertDraftPatchSchema, applyDraftPatch, errorGroupToResolve, + queryWatchFromPatch, type ToolResultPayload, } from "./draftPatch"; import type { SystemLine } from "~/components/alerts/CreationTranscript"; +import type { QueryWatchPrefill } from "~/components/alerts/QueryAlertTranscript"; import type { ResolveErrorGroup } from "~/components/alerts/errorGroupOptions"; import { encodeErrorGroupScope } from "~/utils/errorGroupScope"; @@ -38,6 +40,7 @@ export type CreationAgentSendDeps = { onPatchApplied: (next: FlowAnswers) => void; resetKey?: number; resolveErrorGroup?: ResolveErrorGroup; + onQueryWatch?: (watch: QueryWatchPrefill) => void; }; export type CreationAgentSend = { @@ -58,6 +61,7 @@ export function useCreationAgentSend({ onPatchApplied, resetKey, resolveErrorGroup, + onQueryWatch, }: CreationAgentSendDeps): CreationAgentSend { const [systemLines, setSystemLines] = useState([]); useEffect(() => { @@ -80,16 +84,21 @@ export function useCreationAgentSend({ }); const onPatchAppliedRef = useRef(onPatchApplied); const resolveErrorGroupRef = useRef(resolveErrorGroup); + const onQueryWatchRef = useRef(onQueryWatch); useEffect(() => { onPatchAppliedRef.current = onPatchApplied; resolveErrorGroupRef.current = resolveErrorGroup; + onQueryWatchRef.current = onQueryWatch; }); const handleDraftPatches = useCallback((results: ToolResultPayload[]) => { for (const result of results) { const parsed = result.ok && result.patch ? alertDraftPatchSchema.safeParse(result.patch) : undefined; - if (parsed?.success) { + const queryWatch = parsed?.success ? queryWatchFromPatch(parsed.data) : undefined; + if (queryWatch) { + onQueryWatchRef.current?.(queryWatch); + } else if (parsed?.success) { const patched = applyDraftPatch(answersRef.current, parsed.data); onPatchAppliedRef.current(patched); answersRef.current = patched; diff --git a/apps/webapp/app/components/dashboard-agent/tool-labels.ts b/apps/webapp/app/components/dashboard-agent/tool-labels.ts index 1096292171f..f5ebf4ab441 100644 --- a/apps/webapp/app/components/dashboard-agent/tool-labels.ts +++ b/apps/webapp/app/components/dashboard-agent/tool-labels.ts @@ -27,6 +27,7 @@ const TOOL_LABELS: Record = { delete_watch_subscription: "Deleting an alert", describe_alert_capabilities: "Listing alert capabilities", create_monitoring_alert: "Creating an alert", + analyze_query_alert: "Checking the query for an alert", update_alert_draft: "Updating the alert draft", list_monitoring_alerts: "Listing alerts", resolve_monitoring_alert: "Resolving an alert", diff --git a/apps/webapp/app/components/primitives/charts/Chart.tsx b/apps/webapp/app/components/primitives/charts/Chart.tsx index e3c6a8d205b..3afadfc5232 100644 --- a/apps/webapp/app/components/primitives/charts/Chart.tsx +++ b/apps/webapp/app/components/primitives/charts/Chart.tsx @@ -114,6 +114,8 @@ const ChartTooltipContent = React.forwardRef< labelKey?: string; /** Optional formatter for numeric values (e.g. bytes, duration) */ valueFormatter?: (value: number) => string; + /** Per-item indicator colour; wins over `color` when it returns a value. */ + itemColor?: (item: { dataKey?: unknown; value?: unknown }) => string | undefined; } >( ( @@ -132,6 +134,7 @@ const ChartTooltipContent = React.forwardRef< nameKey, labelKey, valueFormatter, + itemColor, }, ref ) => { @@ -186,7 +189,8 @@ const ChartTooltipContent = React.forwardRef< // Prefer the series' configured colour over item.color: a threshold/gradient line's // recharts colour is a `url(#…)` gradient ref, which is invalid as a CSS background and // renders no swatch. The config colour is the intended solid series colour. - const indicatorColor = color || itemConfig?.color || item.payload.fill || item.color; + const indicatorColor = + itemColor?.(item) || color || itemConfig?.color || item.payload.fill || item.color; return (
    string | undefined; +let captured: { itemColor?: ItemColor; payload?: { dataKey: string }[] } = {}; + +vi.mock("./Chart", async (importOriginal) => ({ + ...(await importOriginal()), + ChartTooltipContent: (props: typeof captured) => { + captured = props; + return null; + }, +})); + +import { OverlayFilteredTooltip } from "./ChartLine"; + +const WARN = "var(--color-warning)"; + +function render(direction: "above" | "below" = "above") { + renderToStaticMarkup( + createElement(OverlayFilteredTooltip, { + active: true, + overlayThreshold: 3, + overlayDirection: direction, + payload: [ + { dataKey: "a", value: 5 }, + { dataKey: "b", value: 1 }, + { dataKey: "__warningOverlay_a", value: 5 }, + ], + }) + ); + return captured.itemColor!; +} + +describe("overlay tooltip swatches", () => { + it("highlights only the series whose own value crosses the threshold", () => { + const itemColor = render(); + expect(itemColor({ dataKey: "a", value: 5 })).toBe(WARN); + expect(itemColor({ dataKey: "b", value: 1 })).toBeUndefined(); + expect(captured.payload!.map((p) => p.dataKey)).toEqual(["a", "b"]); + }); + + it("honours a below-threshold operator per series", () => { + const itemColor = render("below"); + expect(itemColor({ dataKey: "a", value: 5 })).toBeUndefined(); + expect(itemColor({ dataKey: "b", value: 1 })).toBe(WARN); + }); +}); diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index ef0455430be..77dc4a97516 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -86,7 +86,7 @@ export function warningSwatchColor( : undefined; } -function OverlayFilteredTooltip({ +export function OverlayFilteredTooltip({ overlayThreshold, overlayColor, overlayDirection, @@ -101,12 +101,9 @@ function OverlayFilteredTooltip({ + warningSwatchColor(item.value, overlayThreshold, overlayColor, overlayDirection) + } /> ); } @@ -737,7 +734,8 @@ export function ChartLineRenderer({ (props: ActiveDotProps) => props.cx != null && props.cy != null && - overlayDotVisible(props.payload, key, over) ? ( + overlayDotVisible(props.payload, key, over) && + isOver(props.payload as (typeof data)[number], key) ? ( ReactNode; onClose?: () => void; @@ -169,6 +173,7 @@ const QueryEditorForm = forwardRef< isAdmin: boolean; queryActionUrl: string; maxPeriodDays?: number; + watchAlertsPath?: string; onQuerySubmit?: () => void; onHistorySelected?: (item: QueryHistoryItem) => void; } @@ -183,6 +188,7 @@ const QueryEditorForm = forwardRef< isAdmin, queryActionUrl, maxPeriodDays, + watchAlertsPath, onQuerySubmit, onHistorySelected, }, @@ -327,6 +333,13 @@ const QueryEditorForm = forwardRef< maxPeriodDays={maxPeriodDays} /> )} + {watchAlertsPath ? ( + + ) : null}