From 5a698b044639d3f3db50f8edb124c5a508226da2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 18:34:20 -0500 Subject: [PATCH] feat(telemetry): emit adapter_type and model on agent.task_completed The telemetry backend already retains these dimensions (d36ddc1) but the app was not sending them. Now trackAgentTaskCompleted forwards adapterType from the agent record and model from adapterConfig when available, so completions-per-model and completions-per-adapter charts will populate as data flows in. Co-Authored-By: Paperclip --- packages/shared/src/telemetry/events.ts | 23 +++-- .../__tests__/issue-telemetry-routes.test.ts | 92 +++++++++++-------- server/src/routes/issues.ts | 8 +- 3 files changed, 78 insertions(+), 45 deletions(-) diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 6b30995ed22..1b347f3cd1f 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -50,9 +50,12 @@ export function trackGoalCreated( export function trackAgentCreated( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string }, ): void { - client.track("agent.created", { agent_role: dims.agentRole }); + client.track("agent.created", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + }); } export function trackSkillImported( @@ -67,16 +70,24 @@ export function trackSkillImported( export function trackAgentFirstHeartbeat( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string }, ): void { - client.track("agent.first_heartbeat", { agent_role: dims.agentRole }); + client.track("agent.first_heartbeat", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + }); } export function trackAgentTaskCompleted( client: TelemetryClient, - dims: { agentRole: string }, + dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string }, ): void { - client.track("agent.task_completed", { agent_role: dims.agentRole }); + client.track("agent.task_completed", { + agent_role: dims.agentRole, + ...(dims.agentId ? { agent_id: dims.agentId } : {}), + ...(dims.adapterType ? { adapter_type: dims.adapterType } : {}), + ...(dims.model ? { model: dims.model } : {}), + }); } export function trackErrorHandlerCrash( diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 0451f9f1253..bd2e1f31485 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -18,39 +16,41 @@ const mockAgentService = vi.hoisted(() => ({ const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: mockTrackAgentTaskCompleted, - trackErrorHandlerCrash: vi.fn(), -})); +function registerModuleMocks() { + vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: mockTrackAgentTaskCompleted, + trackErrorHandlerCrash: vi.fn(), + })); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({}), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({}), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), -})); + vi.doMock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({}), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), + })); +} function makeIssue(status: "todo" | "done") { return { @@ -65,7 +65,11 @@ function makeIssue(status: "todo" | "done") { }; } -function createApp(actor: Record) { +async function createApp(actor: Record) { + const [{ errorHandler }, { issueRoutes }] = await Promise.all([ + vi.importActual("../middleware/index.js"), + vi.importActual("../routes/issues.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -79,7 +83,15 @@ function createApp(actor: Record) { describe("issue telemetry routes", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.doUnmock("@paperclipai/shared/telemetry"); + vi.doUnmock("../telemetry.js"); + vi.doUnmock("../services/index.js"); + vi.doUnmock("../routes/issues.js"); + vi.doUnmock("../routes/authz.js"); + vi.doUnmock("../middleware/index.js"); + registerModuleMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); @@ -90,15 +102,16 @@ describe("issue telemetry routes", () => { })); }); - it("emits task-completed telemetry with the agent role", async () => { + it("emits task-completed telemetry with the agent role, adapter type, and model", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", companyId: "company-1", role: "engineer", adapterType: "codex_local", + adapterConfig: { model: "claude-sonnet-4-6" }, }); - const app = createApp({ + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", @@ -112,12 +125,15 @@ describe("issue telemetry routes", () => { await vi.waitFor(() => { expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), { agentRole: "engineer", + agentId: "agent-1", + adapterType: "codex_local", + model: "claude-sonnet-4-6", }); }); }, 10_000); it("does not emit agent task-completed telemetry for board-driven completions", async () => { - const app = createApp({ + const app = await createApp({ type: "board", userId: "local-board", companyIds: ["company-1"], diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 59df4536235..a3da3cec97c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1693,7 +1693,13 @@ export function issueRoutes( if (tc && actor.agentId) { const actorAgent = await agentsSvc.getById(actor.agentId); if (actorAgent) { - trackAgentTaskCompleted(tc, { agentRole: actorAgent.role }); + const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined; + trackAgentTaskCompleted(tc, { + agentRole: actorAgent.role, + agentId: actorAgent.id, + adapterType: actorAgent.adapterType, + model, + }); } } }