Merge pull request #2657 from paperclipai/fix/inbox-last-activity-ordering

Add versioned telemetry events
This commit is contained in:
Dotta 2026-04-03 14:19:05 -05:00 committed by GitHub
commit f8452a4520
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 613 additions and 0 deletions

View file

@ -58,6 +58,7 @@ export class TelemetryClient {
app,
schemaVersion,
installId: state.installId,
version: this.version,
events,
}),
signal: controller.signal,

View file

@ -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 },

View file

@ -5,6 +5,12 @@ export {
trackInstallStarted,
trackInstallCompleted,
trackCompanyImported,
trackProjectCreated,
trackRoutineCreated,
trackRoutineRun,
trackGoalCreated,
trackAgentCreated,
trackSkillImported,
trackAgentFirstHeartbeat,
trackAgentTaskCompleted,
trackErrorHandlerCrash,

View file

@ -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"

View file

@ -51,12 +51,22 @@ 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", () => ({
trackAgentCreated: mockTrackAgentCreated,
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
@ -132,6 +142,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 +341,9 @@ describe("agent skill routes", () => {
}),
}),
);
expect(mockTrackAgentCreated).toHaveBeenCalledWith(expect.anything(), {
agentRole: "engineer",
});
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {

View file

@ -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<typeof import("@paperclipai/shared/telemetry")>(
"@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<string, unknown>) {
describe("company skill mutation permissions", () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockCompanySkillService.importFromSource.mockResolvedValue({
imported: [],
warnings: [],
@ -68,6 +85,140 @@ 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("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",

View file

@ -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<typeof import("@paperclipai/shared/telemetry")>(
"@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<typeof projectRoutes> | ReturnType<typeof goalRoutes>) {
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" });
});
});

View file

@ -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.js", () => ({
getTelemetryClient: () => mockTelemetryClient,
}));
vi.mock("@paperclipai/shared/telemetry", async () => {
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
"@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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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",
});
});
});

View file

@ -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<typeof import("@paperclipai/shared/telemetry")>(
"@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<string, unknown>) {
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());
});
});

View file

@ -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);

View file

@ -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<string, string> = {
@ -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,

View file

@ -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<string, unknown> | null;
};
export function companySkillRoutes(db: Db) {
const router = Router();
@ -22,6 +32,26 @@ export function companySkillRoutes(db: Db) {
return Boolean((agent.permissions as Record<string, unknown>).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);
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);
},

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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;
}