mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
parent
ca5659f734
commit
34044cdfce
29 changed files with 670 additions and 5 deletions
122
server/src/__tests__/issue-telemetry-routes.test.ts
Normal file
122
server/src/__tests__/issue-telemetry-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
29
server/src/telemetry.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue