diff --git a/src/events/types.ts b/src/events/types.ts index 915b0e2..f8979bb 100644 --- a/src/events/types.ts +++ b/src/events/types.ts @@ -1,4 +1,14 @@ -export type EventSource = { type: "github"; installationId: number }; +export type TraceContext = { + rayId?: string; + traceId?: string; + spanId?: string; +}; + +export type EventSource = { + type: "github"; + installationId: number; + trace?: TraceContext; +}; export type TaskRequestedEvent = { type: "task_requested"; diff --git a/src/main.test.ts b/src/main.test.ts index 9c1c0ae..a18aa51 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from "vitest"; -import worker from "./main"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import worker, { __setAppBotLoginForTests } from "./main"; +import issuesAssigned from "./testing/fixtures/issues-assigned.json"; +import workflowRunSuccess from "./testing/fixtures/workflow-run-success.json"; +import { computeSignature } from "./testing/workflow-test-helpers"; describe("Worker default export", () => { test("has a fetch handler", () => { @@ -12,3 +15,245 @@ describe("Worker default export", () => { expect(res.status).toBe(404); }); }); + +// ── Worker tracing bindings ─────────────────────────────────────────────────── +// +// The Worker's `handleGithubWebhook` builds `reqLogger` after +// `parseWebhookRequest` succeeds and immediately emits a "Webhook received" +// log line on it. We drive a real signed webhook through `worker.fetch`, spy +// on `console.log` (JSON mode), then inspect the emitted record to assert +// which tracing bindings ended up on the child logger. + +const TEST_SECRET = "test-webhook-secret"; +const TEST_APP_ID = "123"; +const TEST_PRIVATE_KEY = + "-----BEGIN RSA PRIVATE KEY-----\nfake-key\n-----END RSA PRIVATE KEY-----"; +const AGENT_LOGIN = "xmtp-coder-agent"; + +const baseEnv = { + APP_ID: TEST_APP_ID, + PRIVATE_KEY: TEST_PRIVATE_KEY, + WEBHOOK_SECRET: TEST_SECRET, + AGENT_GITHUB_USERNAME: AGENT_LOGIN, + CODER_URL: "https://coder.example.com", + CODER_TOKEN: "token", + CODER_TASK_NAME_PREFIX: "gh", + CODER_TEMPLATE_NAME: "task-template", + CODER_TEMPLATE_NAME_CODEX: "task-template-codex", + CODER_ORGANIZATION: "default", + LOG_FORMAT: "json", +}; + +function makeTracingEnv() { + return { + ...baseEnv, + TASK_RUNNER_WORKFLOW: { + create: (args: { id: string; params: unknown }) => + Promise.resolve({ id: args.id }), + }, + } as unknown as Parameters[1]; +} + +interface BuildReqOpts { + headers?: Record; +} + +async function buildWebhookRequestWithHeaders( + opts: BuildReqOpts = {}, +): Promise { + const body = JSON.stringify(workflowRunSuccess); + const signature = await computeSignature(TEST_SECRET, body); + return new Request("https://example.com/webhooks/github", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "workflow_run", + "X-GitHub-Delivery": "trace-delivery-1", + ...(opts.headers ?? {}), + }, + body, + }); +} + +/** + * Invoke the Worker with the given request, capture every `console.log` call, + * and return the JSON record (if any) whose `msg === "Webhook received"`. + */ +async function captureWebhookReceivedLog( + req: Request, +): Promise> { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await worker.fetch(req, makeTracingEnv(), {} as ExecutionContext); + } finally { + // Spy cleanup is handled by `afterEach(vi.restoreAllMocks)` in the + // enclosing describe block. + } + const entries: Record[] = []; + for (const call of spy.mock.calls) { + const arg = call[0]; + if (typeof arg !== "string") continue; + try { + entries.push(JSON.parse(arg) as Record); + } catch { + // non-JSON console.log output — ignore. + } + } + const match = entries.find((e) => e.msg === "Webhook received"); + if (!match) { + throw new Error( + `"Webhook received" log not emitted. Captured: ${JSON.stringify(entries)}`, + ); + } + return match; +} + +describe("Worker tracing bindings", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + __setAppBotLoginForTests("xmtp-coder-tasks[bot]"); + }); + + test("cf-ray + valid traceparent → rayId, traceId, spanId bound on reqLogger", async () => { + const req = await buildWebhookRequestWithHeaders({ + headers: { + "cf-ray": "8a1-SJC", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + }, + }); + const log = await captureWebhookReceivedLog(req); + expect(log.deliveryId).toBe("trace-delivery-1"); + expect(log.eventName).toBe("workflow_run"); + expect(log.rayId).toBe("8a1-SJC"); + expect(log.traceId).toBe("0af7651916cd43dd8448eb211c80319c"); + expect(log.spanId).toBe("b7ad6b7169203331"); + }); + + test("missing cf-ray and missing traceparent → no rayId/traceId/spanId keys", async () => { + const req = await buildWebhookRequestWithHeaders(); + const log = await captureWebhookReceivedLog(req); + expect(log.deliveryId).toBe("trace-delivery-1"); + expect(log.eventName).toBe("workflow_run"); + expect("rayId" in log).toBe(false); + expect("traceId" in log).toBe(false); + expect("spanId" in log).toBe(false); + }); + + test("cf-ray present but traceparent malformed → rayId only", async () => { + const req = await buildWebhookRequestWithHeaders({ + headers: { + "cf-ray": "9zz-IAD", + traceparent: "garbage", + }, + }); + const log = await captureWebhookReceivedLog(req); + expect(log.deliveryId).toBe("trace-delivery-1"); + expect(log.eventName).toBe("workflow_run"); + expect(log.rayId).toBe("9zz-IAD"); + expect("traceId" in log).toBe(false); + expect("spanId" in log).toBe(false); + }); +}); + +// ── Worker → Workflow trace context propagation ────────────────────────────── + +async function buildDispatchingWebhook( + headers: Record, +): Promise { + const body = JSON.stringify(issuesAssigned); + const signature = await computeSignature(TEST_SECRET, body); + return new Request("https://example.com/webhooks/github", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": "trace-propagation-1", + ...headers, + }, + body, + }); +} + +function makeCapturingEnv(captured: { params?: unknown }) { + return { + ...baseEnv, + TASK_RUNNER_WORKFLOW: { + create: (args: { id: string; params: unknown }) => { + captured.params = args.params; + return Promise.resolve({ id: args.id }); + }, + }, + } as unknown as Parameters[1]; +} + +describe("Worker → Workflow trace context propagation", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + __setAppBotLoginForTests("xmtp-coder-tasks[bot]"); + }); + + test("rayId + valid traceparent are merged onto source.trace in workflow params", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const captured: { params?: unknown } = {}; + const req = await buildDispatchingWebhook({ + "cf-ray": "8a1-SJC", + traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + }); + const res = await worker.fetch( + req, + makeCapturingEnv(captured), + {} as ExecutionContext, + ); + expect(res.status).toBe(202); + const params = captured.params as { + source: { + trace?: { rayId?: string; traceId?: string; spanId?: string }; + }; + }; + expect(params.source.trace).toEqual({ + rayId: "8a1-SJC", + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + }); + }); + + test("no tracing headers → source.trace is not set", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const captured: { params?: unknown } = {}; + const req = await buildDispatchingWebhook({}); + const res = await worker.fetch( + req, + makeCapturingEnv(captured), + {} as ExecutionContext, + ); + expect(res.status).toBe(202); + const params = captured.params as { source: { trace?: unknown } }; + expect(params.source.trace).toBeUndefined(); + }); + + test("cf-ray only → source.trace carries rayId but no trace/span ids", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const captured: { params?: unknown } = {}; + const req = await buildDispatchingWebhook({ "cf-ray": "9zz-IAD" }); + const res = await worker.fetch( + req, + makeCapturingEnv(captured), + {} as ExecutionContext, + ); + expect(res.status).toBe(202); + const params = captured.params as { + source: { + trace?: { rayId?: string; traceId?: string; spanId?: string }; + }; + }; + expect(params.source.trace).toEqual({ rayId: "9zz-IAD" }); + }); +}); diff --git a/src/main.ts b/src/main.ts index 7d3cee7..e8c45be 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,13 +7,13 @@ import { parseWebhookRequest, WebhookRequestError, } from "./http/parse-webhook-request"; -import { createLogger } from "./utils/logger"; +import { createLogger, parseTraceparent } from "./utils/logger"; import { WebhookRouter } from "./webhooks/github/router"; -import type { TaskRunnerWorkflowEnv } from "./workflows/task-runner-workflow"; import { buildInstanceId, isDuplicateInstanceError, } from "./workflows/instance-id"; +import type { TaskRunnerWorkflowEnv } from "./workflows/task-runner-workflow"; export { TaskRunnerWorkflow } from "./workflows/task-runner-workflow"; export { __setAppBotLoginForTests }; @@ -59,7 +59,14 @@ async function handleGithubWebhook( } const { eventName, deliveryId, payload } = parsed; - const reqLogger = logger.child({ deliveryId, eventName }); + const rayId = request.headers.get("cf-ray"); + const trace = parseTraceparent(request.headers.get("traceparent")); + const reqLogger = logger.child({ + deliveryId, + eventName, + ...(rayId ? { rayId } : {}), + ...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}), + }); reqLogger.info("Webhook received"); // Stage 2: route via WebhookRouter. @@ -85,6 +92,13 @@ async function handleGithubWebhook( } // Stage 3: dispatch to Workflow (fire-and-return-202). + const sourceTrace = { + ...(rayId ? { rayId } : {}), + ...(trace ? { traceId: trace.traceId, spanId: trace.spanId } : {}), + }; + if (Object.keys(sourceTrace).length > 0) { + result.source.trace = sourceTrace; + } const instanceId = buildInstanceId(result, deliveryId); try { await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params: result }); diff --git a/src/utils/logger.test.ts b/src/utils/logger.test.ts index 55e24dd..e565379 100644 --- a/src/utils/logger.test.ts +++ b/src/utils/logger.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { createLogger, TestLogger } from "./logger"; +import { createLogger, parseTraceparent, TestLogger } from "./logger"; describe("TestLogger", () => { test("captures info messages", () => { @@ -135,3 +135,164 @@ describe("createLogger(json mode)", () => { } }); }); + +describe("parseTraceparent", () => { + test("parses a valid W3C traceparent header", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ), + ).toEqual({ + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + }); + }); + + test("returns null for a null header", () => { + expect(parseTraceparent(null)).toBeNull(); + }); + + test("returns null when there are too few segments", () => { + expect( + parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331"), + ).toBeNull(); + }); + + test("returns null when there are too many segments", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra", + ), + ).toBeNull(); + }); + + test("returns null for non-hex characters", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319z-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("returns null when trace-id is too short", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("returns null when span-id is too short", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b716920333-01", + ), + ).toBeNull(); + }); + + test("returns null when trace-id is all zeros", () => { + expect( + parseTraceparent( + "00-00000000000000000000000000000000-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("returns null for version ff", () => { + expect( + parseTraceparent( + "ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("accepts forward-compatible versions like 01", () => { + expect( + parseTraceparent( + "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ), + ).toEqual({ + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + }); + }); + + test("returns null for empty string", () => { + expect(parseTraceparent("")).toBeNull(); + }); + + test("returns null for whitespace-only header", () => { + expect(parseTraceparent(" ")).toBeNull(); + }); + + test("returns null for lone dashes", () => { + expect(parseTraceparent("---")).toBeNull(); + }); + + test("returns null for trailing hyphen (5 segments)", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-", + ), + ).toBeNull(); + }); + + test("returns null for uppercase hex in trace-id", () => { + expect( + parseTraceparent( + "00-0AF7651916CD43DD8448EB211C80319C-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("returns null for uppercase hex in span-id", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-B7AD6B7169203331-01", + ), + ).toBeNull(); + }); + + test("accepts all-zero span-id", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01", + ), + ).toEqual({ + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "0000000000000000", + }); + }); + + test("returns null for malformed flags (non-hex)", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-zz", + ), + ).toBeNull(); + }); + + test("returns null for malformed flags (wrong length)", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-012", + ), + ).toBeNull(); + }); + + test("returns null for too-long trace-id (33 hex chars)", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c0-b7ad6b7169203331-01", + ), + ).toBeNull(); + }); + + test("returns null for too-long span-id (17 hex chars)", () => { + expect( + parseTraceparent( + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b71692033310-01", + ), + ).toBeNull(); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 3b1959d..dc76006 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -57,6 +57,40 @@ function makeLogger(bindings: Record, json: boolean): Logger { }; } +// ── W3C traceparent ───────────────────────────────────────────────────────── +// +// Rolled inline instead of taking a library dep: Cloudflare does not expose +// the parsed traceparent to request-path user code (the `SpanContext` type +// in @cloudflare/workers-types is Tail-Worker-only), `@opentelemetry/core` +// requires a Context/TextMapGetter dance just to read two hex strings, +// `tctx` has <3k weekly DL + single maintainer, and `elastic/traceparent` +// is inactive. Rules below are W3C Trace Context §3.2. +// https://www.w3.org/TR/trace-context/#traceparent-header + +const HEX_RE = /^[0-9a-f]+$/; +const ZERO_TRACE_ID = "00000000000000000000000000000000"; + +export function parseTraceparent( + header: string | null, +): { traceId: string; spanId: string } | null { + if (header == null) return null; + const segments = header.split("-"); + if (segments.length !== 4) return null; + const [version, traceId, spanId, flags] = segments as [ + string, + string, + string, + string, + ]; + if (version.length !== 2 || !HEX_RE.test(version)) return null; + if (version === "ff") return null; + if (traceId.length !== 32 || !HEX_RE.test(traceId)) return null; + if (traceId === ZERO_TRACE_ID) return null; + if (spanId.length !== 16 || !HEX_RE.test(spanId)) return null; + if (flags.length !== 2 || !HEX_RE.test(flags)) return null; + return { traceId, spanId }; +} + // ── Test logger ───────────────────────────────────────────────────────────── interface LogEntry { diff --git a/src/workflows/task-runner-workflow.test.ts b/src/workflows/task-runner-workflow.test.ts index bdecae0..33f5240 100644 --- a/src/workflows/task-runner-workflow.test.ts +++ b/src/workflows/task-runner-workflow.test.ts @@ -1,6 +1,5 @@ -import { env } from "cloudflare:test"; -import { introspectWorkflowInstance } from "cloudflare:test"; -import { describe, expect, test } from "vitest"; +import { env, introspectWorkflowInstance } from "cloudflare:test"; +import { describe, expect, test, vi } from "vitest"; import type { CheckFailedEvent, CommentPostedEvent, @@ -81,6 +80,201 @@ describe("TaskRunnerWorkflow dispatch — task_requested", () => { // workflow engine treats as "no mock set" — the real callback then runs and // hits the live GitHub API. This is a pool-workers/miniflare limitation, // not a defect in our workflow logic. + + test("run() binds instanceId onto logger so step emissions carry it", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const instanceId = "task_requested-repo-1-trace-delivery"; + await using instance = await introspectWorkflowInstance( + env.TASK_RUNNER_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: "lookup-coder-user" }, "coder-user"); + await m.mockStepResult({ name: "check-github-permission" }, true); + await m.mockStepResult( + { name: "create-coder-task" }, + { + taskName: "gh-repo-1", + owner: "coder-user", + taskId: "11111111-1111-4111-8111-111111111111", + url: "https://coder.example.com/tasks/coder-user/abc", + status: "ready", + }, + ); + await m.mockStepResult({ name: "comment-on-issue" }, {}); + await m.mockStepResult( + { name: "wait-lookup-task" }, + { status: "active" }, + ); + await m.mockStepResult({ name: "update-status-comment" }, {}); + }); + + const params: TaskRequestedEvent = { + type: "task_requested", + source: { type: "github", installationId: 1 }, + repository: { owner: "acme", name: "repo" }, + issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + requester: { login: "alice", externalId: 42 }, + }; + await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); + await instance.waitForStatus("complete"); + + const withInstanceId = spy.mock.calls + .map((c) => c[0]) + .filter((s): s is string => typeof s === "string") + .map((s) => { + try { + return JSON.parse(s); + } catch { + return null; + } + }) + .filter((o): o is Record => o !== null) + .filter((o) => o.instanceId === instanceId); + + expect(withInstanceId.length).toBeGreaterThan(0); + } finally { + spy.mockRestore(); + } + }); + + test("run() propagates source.trace fields (rayId/traceId/spanId) onto logger", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const instanceId = "task_requested-repo-1-trace-propagated"; + await using instance = await introspectWorkflowInstance( + env.TASK_RUNNER_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: "lookup-coder-user" }, "coder-user"); + await m.mockStepResult({ name: "check-github-permission" }, true); + await m.mockStepResult( + { name: "create-coder-task" }, + { + taskName: "gh-repo-1", + owner: "coder-user", + taskId: "22222222-2222-4222-8222-222222222222", + url: "https://coder.example.com/tasks/coder-user/abc", + status: "ready", + }, + ); + await m.mockStepResult({ name: "comment-on-issue" }, {}); + await m.mockStepResult( + { name: "wait-lookup-task" }, + { status: "active" }, + ); + await m.mockStepResult({ name: "update-status-comment" }, {}); + }); + + const params: TaskRequestedEvent = { + type: "task_requested", + source: { + type: "github", + installationId: 1, + trace: { + rayId: "8a1-SJC", + traceId: "0af7651916cd43dd8448eb211c80319c", + spanId: "b7ad6b7169203331", + }, + }, + repository: { owner: "acme", name: "repo" }, + issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + requester: { login: "alice", externalId: 42 }, + }; + await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); + await instance.waitForStatus("complete"); + + const matching = spy.mock.calls + .map((c) => c[0]) + .filter((s): s is string => typeof s === "string") + .map((s) => { + try { + return JSON.parse(s); + } catch { + return null; + } + }) + .filter((o): o is Record => o !== null) + .filter((o) => o.instanceId === instanceId); + + expect(matching.length).toBeGreaterThan(0); + const first = matching[0]; + expect(first).toBeDefined(); + expect(first?.rayId).toBe("8a1-SJC"); + expect(first?.traceId).toBe("0af7651916cd43dd8448eb211c80319c"); + expect(first?.spanId).toBe("b7ad6b7169203331"); + } finally { + spy.mockRestore(); + } + }); + + test("run() omits rayId/traceId/spanId when source.trace is absent", async () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + const instanceId = "task_requested-repo-1-no-trace"; + await using instance = await introspectWorkflowInstance( + env.TASK_RUNNER_WORKFLOW, + instanceId, + ); + await instance.modify(async (m) => { + await m.disableSleeps(); + await m.mockStepResult({ name: "lookup-coder-user" }, "coder-user"); + await m.mockStepResult({ name: "check-github-permission" }, true); + await m.mockStepResult( + { name: "create-coder-task" }, + { + taskName: "gh-repo-1", + owner: "coder-user", + taskId: "33333333-3333-4333-8333-333333333333", + url: "https://coder.example.com/tasks/coder-user/abc", + status: "ready", + }, + ); + await m.mockStepResult({ name: "comment-on-issue" }, {}); + await m.mockStepResult( + { name: "wait-lookup-task" }, + { status: "active" }, + ); + await m.mockStepResult({ name: "update-status-comment" }, {}); + }); + + const params: TaskRequestedEvent = { + type: "task_requested", + source: { type: "github", installationId: 1 }, + repository: { owner: "acme", name: "repo" }, + issue: { number: 1, url: "https://github.com/acme/repo/issues/1" }, + requester: { login: "alice", externalId: 42 }, + }; + await env.TASK_RUNNER_WORKFLOW.create({ id: instanceId, params }); + await instance.waitForStatus("complete"); + + const matching = spy.mock.calls + .map((c) => c[0]) + .filter((s): s is string => typeof s === "string") + .map((s) => { + try { + return JSON.parse(s); + } catch { + return null; + } + }) + .filter((o): o is Record => o !== null) + .filter((o) => o.instanceId === instanceId); + + expect(matching.length).toBeGreaterThan(0); + for (const entry of matching) { + expect("rayId" in entry).toBe(false); + expect("traceId" in entry).toBe(false); + expect("spanId" in entry).toBe(false); + } + } finally { + spy.mockRestore(); + } + }); }); describe("TaskRunnerWorkflow dispatch — task_closed", () => { diff --git a/src/workflows/task-runner-workflow.ts b/src/workflows/task-runner-workflow.ts index d998141..d3b6792 100644 --- a/src/workflows/task-runner-workflow.ts +++ b/src/workflows/task-runner-workflow.ts @@ -7,9 +7,9 @@ import { createAppAuth } from "@octokit/auth-app"; import { Octokit } from "@octokit/rest"; import { loadConfig } from "../config/app-config"; import type { Event } from "../events/types"; -import { createLogger } from "../utils/logger"; import { CoderService } from "../services/coder/service"; import { GitHubClient } from "../services/github/client"; +import { createLogger } from "../utils/logger"; import { runCloseTask } from "./steps/close-task"; import { runComment } from "./steps/comment"; import { runCreateTask } from "./steps/create-task"; @@ -57,7 +57,18 @@ export class TaskRunnerWorkflow extends WorkflowEntrypoint< const config = loadConfig( this.env as unknown as Record, ); - const logger = createLogger({ logFormat: this.env.LOG_FORMAT }); + const sourceTrace = payload.source.trace ?? {}; + const logger = createLogger({ logFormat: this.env.LOG_FORMAT }).child({ + instanceId: event.instanceId, + ...(sourceTrace.rayId ? { rayId: sourceTrace.rayId } : {}), + ...(sourceTrace.traceId ? { traceId: sourceTrace.traceId } : {}), + ...(sourceTrace.spanId ? { spanId: sourceTrace.spanId } : {}), + }); + // Replay-safe breadcrumb: emits an `instanceId`-tagged line on every + // replay so Workers Logs can correlate the run even when all downstream + // side-effects are cached in `step.do` results. `payload.type` is the + // only payload field logged here — anything sensitive stays out. + logger.info("Workflow run started", { type: payload.type }); const octokit = new Octokit({ authStrategy: createAppAuth, diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 2b6df46..9d010f6 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: f6fe50a66131c4a125d303bca74fa8bb) +// Generated by Wrangler by running `wrangler types` (hash: e3337410ae23afe4d175d62a4fd87cd4) // Runtime types generated with workerd@1.20260415.1 2026-04-18 nodejs_compat declare namespace Cloudflare { interface GlobalProps { @@ -7,7 +7,7 @@ declare namespace Cloudflare { } interface Env { AGENT_GITHUB_USERNAME: "xmtp-coder-agent"; - CODER_URL: "https://coder.example.com"; + CODER_URL: "https://sandbox.xmtp.team"; CODER_TASK_NAME_PREFIX: "gh"; CODER_TEMPLATE_NAME: "task-template"; CODER_TEMPLATE_NAME_CODEX: "task-template-codex"; diff --git a/wrangler.toml b/wrangler.toml index d95314c..19cfa49 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -14,6 +14,14 @@ cpu_ms = 30000 [observability] enabled = true +head_sampling_rate = 1 + +[observability.logs] +invocation_logs = true + +[observability.traces] +enabled = true +head_sampling_rate = 1 [vars] AGENT_GITHUB_USERNAME = "xmtp-coder-agent"