From 37b6ad42eabf9a755150c1acdbb585bad0e51c6c Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 07:32:54 -0500 Subject: [PATCH 1/3] Add versioned telemetry events Co-Authored-By: Paperclip --- packages/shared/src/telemetry/client.ts | 1 + packages/shared/src/telemetry/events.ts | 42 +++++ packages/shared/src/telemetry/index.ts | 6 + packages/shared/src/telemetry/types.ts | 7 + .../src/__tests__/agent-skills-routes.test.ts | 20 +++ .../__tests__/company-skills-routes.test.ts | 109 ++++++++++++ .../project-goal-telemetry-routes.test.ts | 115 ++++++++++++ .../__tests__/routine-run-telemetry.test.ts | 163 ++++++++++++++++++ server/src/__tests__/routines-routes.test.ts | 18 ++ .../__tests__/telemetry-client-flush.test.ts | 19 ++ server/src/routes/agents.ts | 10 ++ server/src/routes/company-skills.ts | 39 +++++ server/src/routes/goals.ts | 6 + server/src/routes/projects.ts | 6 + server/src/routes/routines.ts | 6 + server/src/services/routines.ts | 10 ++ 16 files changed, 577 insertions(+) create mode 100644 server/src/__tests__/project-goal-telemetry-routes.test.ts create mode 100644 server/src/__tests__/routine-run-telemetry.test.ts diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index 939a32ed..a8d6aefb 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -58,6 +58,7 @@ export class TelemetryClient { app, schemaVersion, installId: state.installId, + version: this.version, events, }), signal: controller.signal, diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index 1ed96bb6..6b30995e 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -23,6 +23,48 @@ export function trackCompanyImported( }); } +export function trackProjectCreated(client: TelemetryClient): void { + client.track("project.created"); +} + +export function trackRoutineCreated(client: TelemetryClient): void { + client.track("routine.created"); +} + +export function trackRoutineRun( + client: TelemetryClient, + dims: { source: string; status: string }, +): void { + client.track("routine.run", { + source: dims.source, + status: dims.status, + }); +} + +export function trackGoalCreated( + client: TelemetryClient, + dims?: { goalLevel?: string | null }, +): void { + client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined); +} + +export function trackAgentCreated( + client: TelemetryClient, + dims: { agentRole: string }, +): void { + client.track("agent.created", { agent_role: dims.agentRole }); +} + +export function trackSkillImported( + client: TelemetryClient, + dims: { sourceType: string; skillRef?: string | null }, +): void { + client.track("skill.imported", { + source_type: dims.sourceType, + ...(dims.skillRef ? { skill_ref: dims.skillRef } : {}), + }); +} + export function trackAgentFirstHeartbeat( client: TelemetryClient, dims: { agentRole: string }, diff --git a/packages/shared/src/telemetry/index.ts b/packages/shared/src/telemetry/index.ts index 1757276e..f80de29c 100644 --- a/packages/shared/src/telemetry/index.ts +++ b/packages/shared/src/telemetry/index.ts @@ -5,6 +5,12 @@ export { trackInstallStarted, trackInstallCompleted, trackCompanyImported, + trackProjectCreated, + trackRoutineCreated, + trackRoutineRun, + trackGoalCreated, + trackAgentCreated, + trackSkillImported, trackAgentFirstHeartbeat, trackAgentTaskCompleted, trackErrorHandlerCrash, diff --git a/packages/shared/src/telemetry/types.ts b/packages/shared/src/telemetry/types.ts index a8e3d4dc..d3552d0d 100644 --- a/packages/shared/src/telemetry/types.ts +++ b/packages/shared/src/telemetry/types.ts @@ -24,6 +24,7 @@ export interface TelemetryEventEnvelope { app: string; schemaVersion: string; installId: string; + version: string; events: TelemetryEvent[]; } @@ -31,6 +32,12 @@ export type TelemetryEventName = | "install.started" | "install.completed" | "company.imported" + | "project.created" + | "routine.created" + | "routine.run" + | "goal.created" + | "agent.created" + | "skill.imported" | "agent.first_heartbeat" | "agent.task_completed" | "error.handler_crash" diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 8590d988..71be4cc7 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -51,12 +51,28 @@ const mockSecretService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockAdapter = vi.hoisted(() => ({ listSkills: vi.fn(), syncSkills: vi.fn(), })); +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackAgentCreated: mockTrackAgentCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + vi.mock("../services/index.js", () => ({ agentService: () => mockAgentService, agentInstructionsService: () => mockAgentInstructionsService, @@ -132,6 +148,7 @@ function makeAgent(adapterType: string) { describe("agent skill routes", () => { beforeEach(() => { vi.resetAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.resolveByReference.mockResolvedValue({ ambiguous: false, agent: makeAgent("claude_local"), @@ -330,6 +347,9 @@ describe("agent skill routes", () => { }), }), ); + expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), { + agentRole: "engineer", + }); }); it("materializes a managed AGENTS.md for directly created local agents", async () => { diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 8ac0785d..821dc723 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -18,6 +18,22 @@ const mockCompanySkillService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackSkillImported = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackSkillImported: mockTrackSkillImported, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, @@ -41,6 +57,7 @@ function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ imported: [], warnings: [], @@ -68,6 +85,98 @@ describe("company skill mutation permissions", () => { ); }); + it("tracks public GitHub skill imports with an explicit skill reference", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "vercel-labs/agent-browser/find-skills", + slug: "find-skills", + name: "Find Skills", + description: null, + markdown: "# Find Skills", + sourceType: "github", + sourceLocator: "https://github.com/vercel-labs/agent-browser", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: { + hostname: "github.com", + owner: "vercel-labs", + repo: "agent-browser", + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/vercel-labs/agent-browser" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: "vercel-labs/agent-browser/find-skills", + }); + }); + + it("does not expose a skill reference for non-public skill imports", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "private-skill", + slug: "private-skill", + name: "Private Skill", + description: null, + markdown: "# Private Skill", + sourceType: "github", + sourceLocator: "https://ghe.example.com/acme/private-skill", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: { + hostname: "ghe.example.com", + owner: "acme", + repo: "private-skill", + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://ghe.example.com/acme/private-skill" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: null, + }); + }); + it("blocks same-company agents without management permission from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/project-goal-telemetry-routes.test.ts b/server/src/__tests__/project-goal-telemetry-routes.test.ts new file mode 100644 index 00000000..ac41af63 --- /dev/null +++ b/server/src/__tests__/project-goal-telemetry-routes.test.ts @@ -0,0 +1,115 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { projectRoutes } from "../routes/projects.js"; +import { goalRoutes } from "../routes/goals.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockProjectService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + createWorkspace: vi.fn(), + resolveByReference: vi.fn(), +})); + +const mockGoalService = vi.hoisted(() => ({ + list: vi.fn(), + getById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})); + +const mockWorkspaceOperationService = vi.hoisted(() => ({})); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackProjectCreated = vi.hoisted(() => vi.fn()); +const mockTrackGoalCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackProjectCreated: mockTrackProjectCreated, + trackGoalCreated: mockTrackGoalCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); + +vi.mock("../services/index.js", () => ({ + goalService: () => mockGoalService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + workspaceOperationService: () => mockWorkspaceOperationService, +})); + +vi.mock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), +})); + +function createApp(route: ReturnType | ReturnType) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", route); + app.use(errorHandler); + return app; +} + +describe("project and goal telemetry routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); + mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); + mockProjectService.create.mockResolvedValue({ + id: "project-1", + companyId: "company-1", + name: "Telemetry project", + description: null, + status: "backlog", + }); + mockGoalService.create.mockResolvedValue({ + id: "goal-1", + companyId: "company-1", + title: "Telemetry goal", + description: null, + level: "team", + status: "planned", + }); + mockLogActivity.mockResolvedValue(undefined); + }); + + it("emits telemetry when a project is created", async () => { + const res = await request(createApp(projectRoutes({} as any))) + .post("/api/companies/company-1/projects") + .send({ name: "Telemetry project" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackProjectCreated).toHaveBeenCalledWith(expect.anything()); + }); + + it("emits telemetry when a goal is created", async () => { + const res = await request(createApp(goalRoutes({} as any))) + .post("/api/companies/company-1/goals") + .send({ title: "Telemetry goal", level: "team" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackGoalCreated).toHaveBeenCalledWith(expect.anything(), { goalLevel: "team" }); + }); +}); diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts new file mode 100644 index 00000000..513ba6e3 --- /dev/null +++ b/server/src/__tests__/routine-run-telemetry.test.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + issues, + projectWorkspaces, + projects, + routineRuns, + routines, + routineTriggers, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); +const mockTrackRoutineRun = vi.hoisted(() => vi.fn()); + +vi.mock("../telemetry.ts", () => ({ + getTelemetryClient: () => mockTelemetryClient, +})); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackRoutineRun: mockTrackRoutineRun, + }; +}); + +import { routineService } from "../services/routines.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("routine run telemetry", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routine-telemetry-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + vi.clearAllMocks(); + await db.delete(routineRuns); + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(heartbeatRuns); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedFixture() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Routines", + status: "in_progress", + }); + + const svc = routineService(db, { + heartbeat: { + wakeup: async (wakeupAgentId, wakeupOpts) => { + const issueId = + (typeof wakeupOpts.payload?.issueId === "string" && wakeupOpts.payload.issueId) + || (typeof wakeupOpts.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) + || null; + if (!issueId) return null; + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId, + agentId: wakeupAgentId, + invocationSource: wakeupOpts.source ?? "assignment", + triggerDetail: wakeupOpts.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; + }, + }, + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "Run telemetry test", + description: "Routine body", + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + return { routine, svc }; + } + + it("emits telemetry for routine runs from the service layer", async () => { + const { routine, svc } = await seedFixture(); + + const run = await svc.runRoutine(routine.id, { source: "manual" }); + + expect(run.status).toBe("issue_created"); + expect(mockTrackRoutineRun).toHaveBeenCalledWith(mockTelemetryClient, { + source: "manual", + status: "issue_created", + }); + }); +}); diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index 0c3c0b2b..aeb943c0 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -82,6 +82,22 @@ const mockAccessService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); +const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); + +vi.mock("@paperclipai/shared/telemetry", async () => { + const actual = await vi.importActual( + "@paperclipai/shared/telemetry", + ); + return { + ...actual, + trackRoutineCreated: mockTrackRoutineCreated, + }; +}); + +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); vi.mock("../services/index.js", () => ({ accessService: () => mockAccessService, @@ -104,6 +120,7 @@ function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { vi.clearAllMocks(); + mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); mockRoutineService.getTrigger.mockResolvedValue(trigger); @@ -267,5 +284,6 @@ describe("routine routes", () => { agentId: null, userId: "board-user", }); + expect(mockTrackRoutineCreated).toHaveBeenCalledWith(expect.anything()); }); }); diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts index b057ef9d..2264638c 100644 --- a/server/src/__tests__/telemetry-client-flush.test.ts +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -33,6 +33,25 @@ describe("TelemetryClient periodic flush", () => { await vi.advanceTimersByTimeAsync(1000); expect(fetch).toHaveBeenCalledTimes(1); + const lastCall = vi.mocked(fetch).mock.calls.at(-1); + expect(lastCall?.[0]).toBe("http://localhost:9999/ingest"); + const requestInit = lastCall?.[1] as RequestInit | undefined; + expect(requestInit?.method).toBe("POST"); + expect(requestInit?.headers).toEqual({ "Content-Type": "application/json" }); + const body = JSON.parse(String(requestInit?.body ?? "{}")); + expect(body).toMatchObject({ + app: "paperclip", + schemaVersion: "1", + installId: "test-install", + version: "0.0.0-test", + events: [ + { + name: "install.started", + dimensions: {}, + }, + ], + }); + expect(body.events[0]?.occurredAt).toEqual(expect.any(String)); // Second tick with no new events — no additional call await vi.advanceTimersByTimeAsync(1000); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8ec5ffb1..68084040 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -27,6 +27,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; +import { trackAgentCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { agentService, @@ -62,6 +63,7 @@ import { loadDefaultAgentInstructionsBundle, resolveDefaultAgentInstructionsBundleRole, } from "../services/default-agent-instructions.js"; +import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { @@ -1387,6 +1389,10 @@ export function agentRoutes(db: Db) { desiredSkills: desiredSkillAssignment.desiredSkills, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackAgentCreated(telemetryClient, { agentRole: agent.role }); + } await applyDefaultAgentTaskAssignGrant( companyId, @@ -1469,6 +1475,10 @@ export function agentRoutes(db: Db) { desiredSkills: desiredSkillAssignment.desiredSkills, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackAgentCreated(telemetryClient, { agentRole: agent.role }); + } await applyDefaultAgentTaskAssignGrant( companyId, diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 7b239832..5f2ca739 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -6,10 +6,20 @@ import { companySkillImportSchema, companySkillProjectScanRequestSchema, } from "@paperclipai/shared"; +import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; import { forbidden } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { getTelemetryClient } from "../telemetry.js"; + +type SkillTelemetryInput = { + key: string; + slug: string; + sourceType: string; + sourceLocator: string | null; + metadata: Record | null; +}; export function companySkillRoutes(db: Db) { const router = Router(); @@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) { return Boolean((agent.permissions as Record).canCreateAgents); } + function asString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null { + if (skill.sourceType === "skills_sh") { + return skill.key; + } + if (skill.sourceType !== "github") { + return null; + } + const hostname = asString(skill.metadata?.hostname) ?? "github.com"; + if (hostname !== "github.com") { + return null; + } + return skill.key; + } + async function assertCanMutateCompanySkills(req: Request, companyId: string) { assertCompanyAccess(req, companyId); @@ -183,6 +213,15 @@ export function companySkillRoutes(db: Db) { warningCount: result.warnings.length, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + for (const skill of result.imported) { + trackSkillImported(telemetryClient, { + sourceType: skill.sourceType, + skillRef: deriveTrackedSkillRef(skill), + }); + } + } res.status(201).json(result); }, diff --git a/server/src/routes/goals.ts b/server/src/routes/goals.ts index 450f9467..2f090dad 100644 --- a/server/src/routes/goals.ts +++ b/server/src/routes/goals.ts @@ -1,9 +1,11 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; import { createGoalSchema, updateGoalSchema } from "@paperclipai/shared"; +import { trackGoalCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { goalService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { getTelemetryClient } from "../telemetry.js"; export function goalRoutes(db: Db) { const router = Router(); @@ -42,6 +44,10 @@ export function goalRoutes(db: Db) { entityId: goal.id, details: { title: goal.title }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackGoalCreated(telemetryClient, { goalLevel: goal.level }); + } res.status(201).json(goal); }); diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index b200b354..482a6983 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -7,11 +7,13 @@ import { updateProjectSchema, updateProjectWorkspaceSchema, } from "@paperclipai/shared"; +import { trackProjectCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; +import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { const router = Router(); @@ -107,6 +109,10 @@ export function projectRoutes(db: Db) { workspaceId: createdWorkspaceId, }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackProjectCreated(telemetryClient); + } res.status(201).json(hydratedProject ?? project); }); diff --git a/server/src/routes/routines.ts b/server/src/routes/routines.ts index e7887e88..7045a52d 100644 --- a/server/src/routes/routines.ts +++ b/server/src/routes/routines.ts @@ -8,10 +8,12 @@ import { updateRoutineSchema, updateRoutineTriggerSchema, } from "@paperclipai/shared"; +import { trackRoutineCreated } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, logActivity, routineService } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { forbidden, unauthorized } from "../errors.js"; +import { getTelemetryClient } from "../telemetry.js"; export function routineRoutes(db: Db) { const router = Router(); @@ -76,6 +78,10 @@ export function routineRoutes(db: Db) { entityId: created.id, details: { title: created.title, assigneeAgentId: created.assigneeAgentId }, }); + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackRoutineCreated(telemetryClient); + } res.status(201).json(created); }); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 323462e0..f1f9e1ef 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -31,8 +31,10 @@ import { stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, } from "@paperclipai/shared"; +import { trackRoutineRun } from "@paperclipai/shared/telemetry"; import { conflict, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; import { logger } from "../middleware/logger.js"; +import { getTelemetryClient } from "../telemetry.js"; import { issueService } from "./issues.js"; import { secretService } from "./secrets.js"; import { parseCron, validateCron } from "./cron.js"; @@ -856,6 +858,14 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup } } + const telemetryClient = getTelemetryClient(); + if (telemetryClient) { + trackRoutineRun(telemetryClient, { + source: run.source, + status: run.status, + }); + } + return run; } From 9b3ad6e61672dbc289888409ffd634f2d01026b8 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 09:43:58 -0500 Subject: [PATCH 2/3] Fix telemetry test mocking in agent skill routes Co-Authored-By: Paperclip --- server/src/__tests__/agent-skills-routes.test.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 71be4cc7..eeec658e 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -59,15 +59,9 @@ const mockAdapter = vi.hoisted(() => ({ syncSkills: vi.fn(), })); -vi.mock("@paperclipai/shared/telemetry", async () => { - const actual = await vi.importActual( - "@paperclipai/shared/telemetry", - ); - return { - ...actual, - trackAgentCreated: mockTrackAgentCreated, - }; -}); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, +})); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient, From 68b2fe20bb45eddcf102a892d4b16f441a90bfa9 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 3 Apr 2026 14:11:11 -0500 Subject: [PATCH 3/3] Address Greptile telemetry review comments Co-Authored-By: Paperclip --- .../__tests__/company-skills-routes.test.ts | 42 +++++++++++++++++++ .../__tests__/routine-run-telemetry.test.ts | 2 +- server/src/routes/company-skills.ts | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 821dc723..3814dc08 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -177,6 +177,48 @@ describe("company skill mutation permissions", () => { }); }); + it("does not expose a skill reference when GitHub metadata is missing", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "unknown/private-skill", + slug: "private-skill", + name: "Private Skill", + description: null, + markdown: "# Private Skill", + sourceType: "github", + sourceLocator: "https://github.com/acme/private-skill", + sourceRef: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://github.com/acme/private-skill" }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "github", + skillRef: null, + }); + }); + it("blocks same-company agents without management permission from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/routine-run-telemetry.test.ts b/server/src/__tests__/routine-run-telemetry.test.ts index 513ba6e3..ded45597 100644 --- a/server/src/__tests__/routine-run-telemetry.test.ts +++ b/server/src/__tests__/routine-run-telemetry.test.ts @@ -22,7 +22,7 @@ import { const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); const mockTrackRoutineRun = vi.hoisted(() => vi.fn()); -vi.mock("../telemetry.ts", () => ({ +vi.mock("../telemetry.js", () => ({ getTelemetryClient: () => mockTelemetryClient, })); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 5f2ca739..9e91bf26 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -45,7 +45,7 @@ export function companySkillRoutes(db: Db) { if (skill.sourceType !== "github") { return null; } - const hostname = asString(skill.metadata?.hostname) ?? "github.com"; + const hostname = asString(skill.metadata?.hostname); if (hostname !== "github.com") { return null; }