Files
PaperClipAI/server/src/__tests__/agent-task-run-telemetry.test.ts
T
Nicky LeachandPaperclip 184b014c25 feat(telemetry): add the agent.task_run event and emit it at every terminal run transition (#12809)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip records agent run outcomes through telemetry and run
lifecycle services
> - Terminal run transitions need one consistent event for outcome
analysis
> - The current paths do not report every terminal transition through
one event
> - This pull request adds the agent.task_run event and emits it at each
terminal transition
> - The benefit is complete run outcome data without exposing raw task
identifiers

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Paperclip telemetry reports agent activity, but it does not report every
terminal task run through one event.

**Subsystem affected**

Cross-cutting (multiple of the above): packages/shared telemetry and
server run lifecycle services.

**Current behavior**

Several run paths write a terminal status without a matching
agent.task_run telemetry event.

**Proposed behavior**

Each terminal run transition emits one agent.task_run event. The event
records the terminal state and uses the existing pseudonym helper for
the optional task identifier.

**Reason and benefit**

Complete terminal-run data helps operators measure agent outcomes. The
pseudonym helper prevents the raw task identifier from leaving the
installation.

**Breaking changes**

None. The change adds an event and keeps existing event behavior
compatible.

## What Changed

- Add the agent.task_run telemetry contract and client helper.
- Reuse the existing pseudonym helper for the task identifier. The
helper hashes the identifier with a per-installation salt and returns 16
hexadecimal characters. The raw identifier never leaves the
installation. Existing identifiers do not move.
- Emit one event from each legacy, native, recovery, and issue terminal
transition.
- Keep emissions outside database transactions and make delivery
best-effort.
- Add regression tests for event shape, hashing, terminal transitions,
and emission failures.
- Document the event and its privacy rule in the telemetry data
contract.

## Verification

- `npx tsc --noEmit` in `server/` passes at the submitted commit.
- The pull-request CI suite must pass. CI is the authority because local
Vitest has a known dependency artifact.
- The added regression tests cover event output shape, per-installation
hash divergence, raw identifier handoff, omitted identifiers, and
non-throwing emits.

## Risks

- A missed terminal path could reduce event coverage.
- Telemetry delivery remains best-effort and cannot change run
finalization.
- The pseudonym helper uses installation-specific state, so identifiers
differ between installations.

## Model Used

OpenAI Codex, GPT-5, tool use and code execution. Context window details
were not provided.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I have addressed all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-04 08:21:32 -07:00

292 lines
8.7 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
const mockTrackAgentTaskRun = vi.hoisted(() => vi.fn());
vi.mock("../telemetry.js", () => ({
getTelemetryClient: () => mockTelemetryClient,
}));
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@paperclipai/shared/telemetry",
);
return {
...actual,
trackAgentTaskRun: mockTrackAgentTaskRun,
};
});
import { emitAgentTaskRun } from "../services/agent-task-run-telemetry.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("emitAgentTaskRun", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let companyId!: string;
let agentId!: string;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-task-run-telemetry-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
vi.clearAllMocks();
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedCompanyAndAgent(
agentOverrides: Partial<typeof agents.$inferInsert> = {},
) {
companyId = randomUUID();
agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
defaultResponsibleUserId: "responsible-user",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
...agentOverrides,
});
return { companyId, agentId };
}
it("emits one agent.task_run event for each of the five terminal states", async () => {
await seedCompanyAndAgent();
const states = ["succeeded", "interrupted", "failed", "cancelled", "timed_out"] as const;
for (const state of states) {
mockTrackAgentTaskRun.mockClear();
const runId = randomUUID();
const run = {
id: runId,
companyId,
agentId,
status: state,
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
expect(mockTrackAgentTaskRun).toHaveBeenCalledTimes(1);
expect(mockTrackAgentTaskRun).toHaveBeenCalledWith(
mockTelemetryClient,
expect.objectContaining({ agentId, state }),
);
}
});
it("omits durationSeconds when the run has no startedAt", async () => {
await seedCompanyAndAgent();
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims).not.toHaveProperty("durationSeconds");
});
it("sends whole seconds from startedAt to finishedAt", async () => {
await seedCompanyAndAgent();
const startedAt = new Date("2026-01-01T00:00:00.000Z");
const finishedAt = new Date("2026-01-01T00:00:07.400Z");
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt,
finishedAt,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims.durationSeconds).toBe(7);
expect(Number.isInteger(dims.durationSeconds)).toBe(true);
});
it("never returns a negative durationSeconds when finishedAt precedes startedAt", async () => {
await seedCompanyAndAgent();
const startedAt = new Date("2026-01-01T00:00:10.000Z");
const finishedAt = new Date("2026-01-01T00:00:05.000Z");
const run = {
id: randomUUID(),
companyId,
agentId,
status: "cancelled",
startedAt,
finishedAt,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims.durationSeconds).toBe(0);
});
it("omits model, the three token dimensions, and taskId when each source is absent", async () => {
await seedCompanyAndAgent({ adapterConfig: {} });
const run = {
id: randomUUID(),
companyId,
agentId,
status: "failed",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims).not.toHaveProperty("model");
expect(dims).not.toHaveProperty("inputTokens");
expect(dims).not.toHaveProperty("outputTokens");
expect(dims).not.toHaveProperty("cachedTokens");
expect(dims).not.toHaveProperty("taskId");
});
it("sends cachedTokens: 0 when usage exists and carries no cached value", async () => {
await seedCompanyAndAgent();
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: { inputTokens: 12, outputTokens: 34 },
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims.inputTokens).toBe(12);
expect(dims.outputTokens).toBe(34);
expect(dims.cachedTokens).toBe(0);
});
it("emits with no taskId when contextSnapshot carries no issueId", async () => {
await seedCompanyAndAgent();
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: { wakeReason: "issue_commented" },
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims).not.toHaveProperty("taskId");
});
it("passes the raw task identifier to trackAgentTaskRun without hashing it", async () => {
await seedCompanyAndAgent();
const rawIssueId = randomUUID();
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: { issueId: rawIssueId },
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims.taskId).toBe(rawIssueId);
expect(dims.taskId).not.toMatch(/^[0-9a-f]{32}$/);
});
it("does not throw out of the status write when the emit fails", async () => {
await seedCompanyAndAgent();
mockTrackAgentTaskRun.mockImplementationOnce(() => {
throw new Error("telemetry backend unreachable");
});
const run = {
id: randomUUID(),
companyId,
agentId,
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await expect(emitAgentTaskRun(db, run)).resolves.toBeUndefined();
});
it("omits adapterType and agentRole when the agent row is absent", async () => {
await seedCompanyAndAgent();
const run = {
id: randomUUID(),
companyId,
agentId: randomUUID(),
status: "succeeded",
startedAt: null,
finishedAt: null,
usageJson: null,
contextSnapshot: null,
} as unknown as typeof heartbeatRuns.$inferSelect;
await emitAgentTaskRun(db, run);
const dims = mockTrackAgentTaskRun.mock.calls[0][1];
expect(dims).not.toHaveProperty("adapterType");
expect(dims).not.toHaveProperty("agentRole");
});
});