feat: implement app-side telemetry sender

Add the shared telemetry sender, wire the CLI/server emit points,
and cover the config and completion behavior with tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-31 08:08:18 -05:00
parent ca5659f734
commit 34044cdfce
29 changed files with 670 additions and 5 deletions

View file

@ -0,0 +1,122 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),
goalService: () => ({}),
heartbeatService: () => ({
reportRunActivity: vi.fn(async () => undefined),
}),
issueApprovalService: () => ({}),
issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined),
projectService: () => ({}),
routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined),
}),
workProductService: () => ({}),
}));
function makeIssue(status: "todo" | "done") {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
status,
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
assigneeUserId: null,
createdByUserId: "local-board",
identifier: "PAP-1018",
title: "Telemetry test",
};
}
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use(errorHandler);
return app;
}
describe("issue telemetry routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue("todo"),
...patch,
}));
});
it("emits task-completed telemetry with the agent adapter type", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
adapterType: "codex_local",
});
const res = await request(createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
runId: null,
}))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done" });
expect(res.status).toBe(200);
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
adapterType: "codex_local",
});
});
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
const res = await request(createApp({
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
}))
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ status: "done" });
expect(res.status).toBe(200);
expect(mockTrackAgentTaskCompleted).not.toHaveBeenCalled();
expect(mockAgentService.getById).not.toHaveBeenCalled();
});
});

View file

@ -75,6 +75,7 @@ export interface Config {
heartbeatSchedulerEnabled: boolean;
heartbeatSchedulerIntervalMs: number;
companyDeletionEnabled: boolean;
telemetryEnabled: boolean;
}
export function loadConfig(): Config {
@ -267,5 +268,6 @@ export function loadConfig(): Config {
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
companyDeletionEnabled,
telemetryEnabled: fileConfig?.telemetry?.enabled ?? true,
};
}

View file

@ -39,6 +39,7 @@ import { createStorageServiceFromConfig } from "./storage/index.js";
import { printStartupBanner } from "./startup-banner.js";
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
import { initTelemetry } from "./telemetry.js";
type BetterAuthSessionUser = {
id: string;
@ -79,6 +80,7 @@ export interface StartedServer {
export async function startServer(): Promise<StartedServer> {
let config = loadConfig();
initTelemetry({ enabled: config.telemetryEnabled });
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
}

View file

@ -1,6 +1,8 @@
import type { Request, Response, NextFunction } from "express";
import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
export interface ErrorContext {
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
@ -44,6 +46,8 @@ export function errorHandler(
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
err,
);
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorName: err.name, route: req.route?.path ?? req.path, method: req.method });
}
res.status(err.status).json({
error: err.message,
@ -67,5 +71,8 @@ export function errorHandler(
rootError,
);
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorName: rootError.name, route: req.route?.path ?? req.path, method: req.method });
res.status(500).json({ error: "Internal server error" });
}

View file

@ -20,6 +20,8 @@ import {
upsertIssueDocumentSchema,
updateIssueSchema,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import type { StorageService } from "../storage/types.js";
import { validate } from "../middleware/validate.js";
import {
@ -1177,6 +1179,16 @@ export function issueRoutes(db: Db, storage: StorageService) {
},
});
if (issue.status === "done" && existing.status !== "done") {
const tc = getTelemetryClient();
if (tc && actor.agentId) {
const actorAgent = await agentsSvc.getById(actor.agentId);
if (actorAgent) {
trackAgentTaskCompleted(tc, { adapterType: actorAgent.adapterType });
}
}
}
let comment = null;
if (commentBody) {
comment = await svc.addComment(id, commentBody, {

View file

@ -25,6 +25,8 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { companySkillService } from "./company-skills.js";
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
import { secretService } from "./secrets.js";
@ -1807,6 +1809,8 @@ export function heartbeatService(db: Db) {
return;
}
const isFirstHeartbeat = !existing.lastHeartbeatAt;
const runningCount = await countRunningRunsForAgent(agentId);
const nextStatus =
runningCount > 0
@ -1826,6 +1830,11 @@ export function heartbeatService(db: Db) {
.returning()
.then((rows) => rows[0] ?? null);
if (isFirstHeartbeat && updated) {
const tc = getTelemetryClient();
if (tc) trackAgentFirstHeartbeat(tc, { adapterType: updated.adapterType });
}
if (updated) {
publishLiveEvent({
companyId: updated.companyId,

29
server/src/telemetry.ts Normal file
View file

@ -0,0 +1,29 @@
import path from "node:path";
import {
TelemetryClient,
resolveTelemetryConfig,
loadOrCreateState,
} from "@paperclipai/shared/telemetry";
import { resolvePaperclipInstanceRoot } from "./home-paths.js";
import { serverVersion } from "./version.js";
let client: TelemetryClient | null = null;
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
if (client) return client;
const config = resolveTelemetryConfig(fileConfig);
if (!config.enabled) return null;
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
client = new TelemetryClient(
config,
() => loadOrCreateState(stateDir, serverVersion),
serverVersion,
);
return client;
}
export function getTelemetryClient(): TelemetryClient | null {
return client;
}