diff --git a/packages/shared/src/telemetry/client.test.ts b/packages/shared/src/telemetry/client.test.ts index 5d3fc630d99..9fd20e75954 100644 --- a/packages/shared/src/telemetry/client.test.ts +++ b/packages/shared/src/telemetry/client.test.ts @@ -30,6 +30,28 @@ describe("TelemetryClient runtime event gate", () => { vi.restoreAllMocks(); }); + it("swallows proposed first-party events before they touch state or the queue", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + const { client, stateFactory } = makeClient(); + + client.track( + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): fixture proposal not in generated schema + "skill.created", + { + skill_id: "skill-1", + creation_source: "blank", + sharing_scope: "company", + category_count: 1, + file_count: 1, + }, + ); + + await client.flush(); + + expect(stateFactory).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + it("uses own-property membership so prototype event names are swallowed", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); const { client, stateFactory } = makeClient(); diff --git a/packages/shared/src/telemetry/events.ts b/packages/shared/src/telemetry/events.ts index e4ba59553b8..c3b50b90c6c 100644 --- a/packages/shared/src/telemetry/events.ts +++ b/packages/shared/src/telemetry/events.ts @@ -95,22 +95,22 @@ export function trackSkillImported( export function trackSkillCreated( client: TelemetryClient, dims: { - skillId: string; - creationSource: RawDimension<"blank" | "fork" | "project_scan">; - sharingScope: RawDimension<"private" | "company" | "public_link">; - categoryCount: number; - fileCount: number; + skill_id: string; + creation_source: RawDimension<"blank" | "fork" | "project_scan">; + sharing_scope: RawDimension<"private" | "company" | "public_link">; + category_count: number; + file_count: number; }, ): void { client.track( - // @ts-expect-error -- proposed-telemetry: measure successful Skill Studio skill creation funnel + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): measure successful Skill Studio skill creation funnel "skill.created", { - skill_id: dims.skillId, - creation_source: asEventDimension(dims.creationSource), - sharing_scope: asEventDimension(dims.sharingScope), - category_count: dims.categoryCount, - file_count: dims.fileCount, + skill_id: dims.skill_id, + creation_source: asEventDimension(dims.creation_source), + sharing_scope: asEventDimension(dims.sharing_scope), + category_count: dims.category_count, + file_count: dims.file_count, }, ); } @@ -118,18 +118,18 @@ export function trackSkillCreated( export function trackSkillVersionSaved( client: TelemetryClient, dims: { - skillId: string; - revisionNumber: number; - fileType: RawDimension<"skill" | "markdown" | "reference" | "script" | "asset" | "other">; + skill_id: string; + revision_number: number; + file_type: RawDimension<"skill" | "markdown" | "reference" | "script" | "asset" | "other">; }, ): void { client.track( - // @ts-expect-error -- proposed-telemetry: measure Skill Studio editor save and version creation usage + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): measure Skill Studio editor save and version creation usage "skill.version_saved", { - skill_id: dims.skillId, - revision_number: dims.revisionNumber, - file_type: asEventDimension(dims.fileType), + skill_id: dims.skill_id, + revision_number: dims.revision_number, + file_type: asEventDimension(dims.file_type), }, ); } @@ -137,22 +137,22 @@ export function trackSkillVersionSaved( export function trackSkillTestRun( client: TelemetryClient, dims: { - skillId: string; + skill_id: string; status: RawDimension<"queued" | "running" | "succeeded" | "failed" | "cancelled">; - runSource: RawDimension<"run" | "rerun">; - adHoc: boolean; - templateUsed: boolean; + run_source: RawDimension<"run" | "rerun">; + ad_hoc: boolean; + template_used: boolean; }, ): void { client.track( - // @ts-expect-error -- proposed-telemetry: measure Skill Studio validation loop usage + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): measure Skill Studio validation loop usage "skill.test_run", { - skill_id: dims.skillId, + skill_id: dims.skill_id, status: asEventDimension(dims.status), - run_source: asEventDimension(dims.runSource), - ad_hoc: dims.adHoc, - template_used: dims.templateUsed, + run_source: asEventDimension(dims.run_source), + ad_hoc: dims.ad_hoc, + template_used: dims.template_used, }, ); } @@ -160,22 +160,22 @@ export function trackSkillTestRun( export function trackSkillForked( client: TelemetryClient, dims: { - skillId: string; - forkFromSkillId: string; - sourceType: RawDimension<"local_path" | "github" | "url" | "catalog" | "skills_sh">; - sharingScope: RawDimension<"private" | "company" | "public_link">; - reassignAgentCount: number; + skill_id: string; + fork_from_skill_id: string; + source_type: RawDimension<"local_path" | "github" | "url" | "catalog" | "skills_sh">; + sharing_scope: RawDimension<"private" | "company" | "public_link">; + reassign_agent_count: number; }, ): void { client.track( - // @ts-expect-error -- proposed-telemetry: measure Skill Studio fork completion and reassignment demand + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): measure Skill Studio fork completion and reassignment demand "skill.forked", { - skill_id: dims.skillId, - fork_from_skill_id: dims.forkFromSkillId, - source_type: asEventDimension(dims.sourceType), - sharing_scope: asEventDimension(dims.sharingScope), - reassign_agent_count: dims.reassignAgentCount, + skill_id: dims.skill_id, + fork_from_skill_id: dims.fork_from_skill_id, + source_type: asEventDimension(dims.source_type), + sharing_scope: asEventDimension(dims.sharing_scope), + reassign_agent_count: dims.reassign_agent_count, }, ); } @@ -183,14 +183,14 @@ export function trackSkillForked( export function trackSkillShareLinkCopied( client: TelemetryClient, dims: { - sharingScope: RawDimension<"private" | "company" | "public_link">; + sharing_scope: RawDimension<"private" | "company" | "public_link">; }, ): void { client.track( - // @ts-expect-error -- proposed-telemetry: exercise triage of low-signal share-link copy proposal + // @ts-expect-error -- proposed-telemetry(https://github.com/paperclipai/paperclip/issues/9566): exercise triage of low-signal share-link copy proposal "skill.share_link", { - sharing_scope: asEventDimension(dims.sharingScope), + sharing_scope: asEventDimension(dims.sharing_scope), }, ); } diff --git a/scripts/extract-proposed-events.mjs b/scripts/extract-proposed-events.mjs index fb9e3643030..ce29ace3fc8 100644 --- a/scripts/extract-proposed-events.mjs +++ b/scripts/extract-proposed-events.mjs @@ -75,11 +75,11 @@ export function extractProposedEvents(options = {}) { const sourceFile = ts.createSourceFile(eventsFile, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const proposals = new Map(); - function recordProposal(wrapper, wrapperName, trackCall, eventNameNode, directive) { + function recordProposal(wrapper, wrapperName, eventNameNode, directive) { const name = eventNameNode.text; assertEventName(name, "event name"); const position = sourceFile.getLineAndCharacterOfPosition(eventNameNode.getStart(sourceFile)); - const dimensions = extractWrapperDimensions(wrapper, sourceFile, wrapperName, trackCall.arguments[1]); + const dimensions = extractWrapperDimensions(wrapper, sourceFile, wrapperName); let proposal = proposals.get(name); if (!proposal) { @@ -118,7 +118,7 @@ export function extractProposedEvents(options = {}) { const eventNameNode = node.arguments[0]; if (eventNameNode && ts.isStringLiteral(eventNameNode)) { const directive = findDirectiveForNode(sourceText, eventNameNode); - if (directive) recordProposal(wrapper, wrapperName, node, eventNameNode, directive); + if (directive) recordProposal(wrapper, wrapperName, eventNameNode, directive); } } ts.forEachChild(node, visit); @@ -193,26 +193,11 @@ function normalizeComment(commentText) { .trim(); } -function extractWrapperDimensions(wrapper, sourceFile, wrapperName = wrapper.name?.text ?? "", payloadNode) { +function extractWrapperDimensions(wrapper, sourceFile, wrapperName = wrapper.name?.text ?? "") { const dimsParam = wrapper.parameters.find( (parameter) => ts.isIdentifier(parameter.name) && parameter.name.text === "dims", ); - - if (!payloadNode) return []; - - if (ts.isObjectLiteralExpression(payloadNode)) { - const dimsMembers = dimsParam ? extractDimsMembers(dimsParam, wrapperName) : new Map(); - return extractPayloadDimensions(payloadNode, dimsMembers, sourceFile); - } - - if (!dimsParam || !ts.isIdentifier(payloadNode) || payloadNode.text !== "dims") { - throw new Error(`wrapper ${wrapperName} proposed telemetry payload must be a dims identifier or object literal`); - } - - return [...extractDimsMembers(dimsParam, wrapperName).values()].map((member) => extractDimension(member, sourceFile)); -} - -function extractDimsMembers(dimsParam, wrapperName) { + if (!dimsParam) return []; if (!dimsParam.type) { throw new Error(`wrapper ${wrapperName} has an untyped dims parameter`); } @@ -221,77 +206,7 @@ function extractDimsMembers(dimsParam, wrapperName) { throw new Error(`wrapper ${wrapperName} dims parameter must be a type literal`); } - const members = new Map(); - for (const member of typeNode.members) { - if (!ts.isPropertySignature(member) || !member.type) { - throw new Error("dims parameter type may contain only typed property signatures"); - } - members.set(propertyNameText(member.name), member); - } - return members; -} - -function extractPayloadDimensions(payloadNode, dimsMembers, sourceFile) { - const dimensions = []; - for (const property of payloadNode.properties) { - if (ts.isSpreadAssignment(property)) { - if (ts.isIdentifier(property.expression) && property.expression.text === "dims") { - for (const member of dimsMembers.values()) { - dimensions.push(extractDimension(member, sourceFile)); - } - continue; - } - throw new Error(`unsupported proposed telemetry spread payload: ${property.getText(sourceFile)}`); - } - - if (ts.isShorthandPropertyAssignment(property)) { - const name = propertyNameText(property.name); - assertEventName(name, "dimension name"); - const member = dimsMembers.get(name); - if (!member) throw new Error(`unable to infer proposed telemetry dimension type for ${name}`); - dimensions.push({ name, type: classifyTypeNode(member.type, sourceFile) }); - continue; - } - - if (!ts.isPropertyAssignment(property)) { - throw new Error(`unsupported proposed telemetry payload property: ${property.getText(sourceFile)}`); - } - - const name = propertyNameText(property.name); - assertEventName(name, "dimension name"); - dimensions.push({ name, type: classifyPayloadExpression(property.initializer, dimsMembers, sourceFile) }); - } - return dimensions; -} - -function classifyPayloadExpression(expression, dimsMembers, sourceFile) { - const dimsProperty = unwrapEventDimensionExpression(expression); - if (dimsProperty) { - const member = dimsMembers.get(dimsProperty); - if (!member) throw new Error(`unable to infer proposed telemetry dimension type for dims.${dimsProperty}`); - return classifyTypeNode(member.type, sourceFile); - } - - if (ts.isStringLiteral(expression) || expression.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral) return "string"; - if (ts.isNumericLiteral(expression)) return "number"; - if (expression.kind === ts.SyntaxKind.TrueKeyword || expression.kind === ts.SyntaxKind.FalseKeyword) return "boolean"; - - throw new Error(`unsupported proposed telemetry payload value: ${expression.getText(sourceFile)}`); -} - -function unwrapEventDimensionExpression(expression) { - if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === "dims") { - return expression.name.text; - } - - if (ts.isCallExpression(expression)) { - const callee = expression.expression; - if (ts.isIdentifier(callee) && callee.text === "asEventDimension" && expression.arguments.length === 1) { - return unwrapEventDimensionExpression(expression.arguments[0]); - } - } - - return null; + return typeNode.members.map((member) => extractDimension(member, sourceFile)); } function extractDimension(member, sourceFile) { diff --git a/scripts/extract-proposed-events.test.mjs b/scripts/extract-proposed-events.test.mjs index 7b473dc8a71..6f3181ff9ce 100644 --- a/scripts/extract-proposed-events.test.mjs +++ b/scripts/extract-proposed-events.test.mjs @@ -96,44 +96,6 @@ test("extractor emits deterministic proposed-telemetry-extractor.v2 records", () assert.equal(typeof created.provenance[0].column, "number"); }); -test("extractor infers proposed dimensions from snake_case payload mapped from camelCase wrapper dims", () => { - const output = withFixtureRepo( - `type TelemetryClient = { track(name: string, dims: unknown): void }; -type RawDimension = T | (string & {}); -function asEventDimension(value: T): T { return value; } - -export function trackSkillCreated( - client: TelemetryClient, - dims: { - skillId: string; - sharingScope: RawDimension<"private" | "company">; - categoryCount: number; - adHoc: boolean; - }, -): void { - client.track( - // @ts-expect-error -- proposed-telemetry(PAP-2411): measure skill creation - "skill.created", - { - skill_id: dims.skillId, - sharing_scope: asEventDimension(dims.sharingScope), - category_count: dims.categoryCount, - ad_hoc: dims.adHoc, - }, - ); -} -`, - ({ repoRoot, eventsFile }) => extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }), - ); - - assert.deepEqual(output.proposals[0].dimensions, [ - { name: "ad_hoc", type: "boolean" }, - { name: "category_count", type: "number" }, - { name: "sharing_scope", type: "string" }, - { name: "skill_id", type: "string" }, - ]); -}); - test("extractor flags a missing proposed-telemetry suffix without hard-failing", () => { const output = withFixtureRepo(fixtureSource, ({ repoRoot, eventsFile }) => extractProposedEvents({ repoRoot, eventsFile, ref: "fixture-sha" }), diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 1bd34a526b5..fff6754ef7b 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -73,7 +73,11 @@ const mockCatalogService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockTrackSkillCreated = vi.hoisted(() => vi.fn()); +const mockTrackSkillForked = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = vi.hoisted(() => vi.fn()); +const mockTrackSkillTestRun = vi.hoisted(() => vi.fn()); +const mockTrackSkillVersionSaved = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); const mockReflectionCoachMutationGate = vi.hoisted(() => ({ assertConsented: vi.fn(), @@ -107,7 +111,11 @@ function registerModuleMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); vi.doMock("@paperclipai/shared/telemetry", () => ({ + trackSkillCreated: mockTrackSkillCreated, + trackSkillForked: mockTrackSkillForked, trackSkillImported: mockTrackSkillImported, + trackSkillTestRun: mockTrackSkillTestRun, + trackSkillVersionSaved: mockTrackSkillVersionSaved, trackErrorHandlerCrash: vi.fn(), })); @@ -694,6 +702,13 @@ describe("company skill mutation permissions", () => { })); expect(mockAccessService.canUser).not.toHaveBeenCalledWith("company-1", "board-user", "agents:create"); expect(mockCompanySkillService.createLocalSkill).toHaveBeenCalled(); + expect(mockTrackSkillCreated).toHaveBeenCalledWith(expect.anything(), { + skill_id: "skill-1", + creation_source: "blank", + sharing_scope: "company", + category_count: 0, + file_count: 1, + }); expect(mockCompanySkillService.importFromSource).toHaveBeenCalled(); expect(mockCompanySkillService.installFromCatalog).toHaveBeenCalled(); expect(mockCompanySkillService.updateSkill).toHaveBeenCalled(); @@ -1080,6 +1095,33 @@ describe("company skill mutation permissions", () => { entityType: "company_skill_version", entityId: "version-1", })); + expect(mockTrackSkillVersionSaved).toHaveBeenCalledWith(expect.anything(), { + skill_id: "skill-1", + revision_number: 1, + file_type: "skill", + }); + }); + + it("tracks Skill Studio file saves with the current version and file kind", async () => { + mockCompanySkillService.detail.mockResolvedValueOnce({ + currentVersion: { revisionNumber: 7 }, + }); + const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" }); + + await request(app) + .patch("/api/companies/company-1/skills/skill-1/files") + .send({ path: "SKILL.md", content: "# Updated" }) + .expect(200); + + expect(mockCompanySkillService.updateFile).toHaveBeenCalledWith("company-1", "skill-1", "SKILL.md", "# Updated", { + type: "user", + userId: "user-1", + }); + expect(mockTrackSkillVersionSaved).toHaveBeenCalledWith(expect.anything(), { + skill_id: "skill-1", + revision_number: 7, + file_type: "skill", + }); }); it("deletes skill files and logs the mutation", async () => { @@ -1158,6 +1200,13 @@ describe("company skill mutation permissions", () => { action: "company.skill_forked", entityId: "skill-fork", })); + expect(mockTrackSkillForked).toHaveBeenCalledWith(expect.anything(), { + skill_id: "skill-fork", + fork_from_skill_id: "skill-1", + source_type: "github", + sharing_scope: "company", + reassign_agent_count: 0, + }); expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "company.skill_comment_created", entityId: "comment-1", @@ -1556,6 +1605,13 @@ describe("company skill mutation permissions", () => { reason: "skill_test_run_created", payload: expect.objectContaining({ issueId: "44444444-4444-4444-8444-444444444444", skillId: "skill-1" }), })); + expect(mockTrackSkillTestRun).toHaveBeenCalledWith(expect.anything(), { + skill_id: "skill-1", + status: "queued", + run_source: "run", + ad_hoc: false, + template_used: true, + }); const cancelled = await request(app) .post("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222/cancel") diff --git a/server/src/__tests__/shared-telemetry-events.test.ts b/server/src/__tests__/shared-telemetry-events.test.ts index c2a190e4eb7..a6959bf9317 100644 --- a/server/src/__tests__/shared-telemetry-events.test.ts +++ b/server/src/__tests__/shared-telemetry-events.test.ts @@ -212,11 +212,11 @@ describe("shared Skill telemetry proposals", () => { it("emits primitive skill.created dimensions and omits user-authored identity", () => { const client = createClient(); const dims = { - skillId: "11111111-1111-4111-8111-111111111111", - creationSource: "blank", - sharingScope: "company", - categoryCount: 2, - fileCount: 3, + skill_id: "11111111-1111-4111-8111-111111111111", + creation_source: "blank", + sharing_scope: "company", + category_count: 2, + file_count: 3, name: "Secret Skill", slug: "secret-skill", tagline: "keep this private", @@ -242,9 +242,9 @@ describe("shared Skill telemetry proposals", () => { it("emits primitive skill.version_saved dimensions without file path or content", () => { const client = createClient(); const dims = { - skillId: "22222222-2222-4222-8222-222222222222", - revisionNumber: 4, - fileType: "markdown", + skill_id: "22222222-2222-4222-8222-222222222222", + revision_number: 4, + file_type: "markdown", file_path: "customers/acme/SKILL.md", file_content: "keep this private", path: "customers/acme/SKILL.md", @@ -269,11 +269,11 @@ describe("shared Skill telemetry proposals", () => { it("emits primitive skill.test_run dimensions without snapshots, prompts, or errors", () => { const client = createClient(); const dims = { - skillId: "33333333-3333-4333-8333-333333333333", + skill_id: "33333333-3333-4333-8333-333333333333", status: "queued", - runSource: "run", - adHoc: true, - templateUsed: false, + run_source: "run", + ad_hoc: true, + template_used: false, inputSnapshot: "do not leak input", outputSnapshot: "do not leak output", harnessIssueDescription: "private harness issue", @@ -302,11 +302,11 @@ describe("shared Skill telemetry proposals", () => { it("emits primitive skill.forked dimensions and passes opaque IDs through raw", () => { const client = createClient(); const dims = { - skillId: "44444444-4444-4444-8444-444444444444", - forkFromSkillId: "55555555-5555-4555-8555-555555555555", - sourceType: "github", - sharingScope: "private", - reassignAgentCount: 1, + skill_id: "44444444-4444-4444-8444-444444444444", + fork_from_skill_id: "55555555-5555-4555-8555-555555555555", + source_type: "github", + sharing_scope: "private", + reassign_agent_count: 1, name: "Secret Skill", slug: "secret-skill", fork_from_name: "Secret Source", @@ -332,7 +332,7 @@ describe("shared Skill telemetry proposals", () => { it("emits only sharing_scope for the deliberately rejectable share-link proposal", () => { const client = createClient(); const dims = { - sharingScope: "public_link", + sharing_scope: "public_link", skill_id: "66666666-6666-4666-8666-666666666666", href: "https://paperclip.example/skills/secret-skill?token=secret-token", url: "https://paperclip.example/skills/secret-skill?token=secret-token", diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index b18a5aec381..8e8e9d32c07 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -23,7 +23,17 @@ import { companySkillUpdateSchema, companySkillVersionCreateSchema, } from "@paperclipai/shared"; -import { trackSkillImported } from "@paperclipai/shared/telemetry"; +import type { + CompanySkillFileInventoryEntry, + CompanySkillSharingScope, +} from "@paperclipai/shared"; +import { + trackSkillCreated, + trackSkillForked, + trackSkillImported, + trackSkillTestRun, + trackSkillVersionSaved, +} from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, companySkillService, heartbeatService, issueService, logActivity } from "../services/index.js"; import { @@ -79,6 +89,25 @@ export function companySkillRoutes(db: Db) { return skill.key; } + function emitSkillTelemetry(run: (client: NonNullable>) => void) { + const telemetryClient = getTelemetryClient(); + if (telemetryClient) run(telemetryClient); + } + + function deriveSkillCreationSource(skill: { forkedFromSkillId?: string | null; metadata?: Record | null }) { + if (skill.forkedFromSkillId) return "fork"; + return asString(skill.metadata?.sourceKind) === "project_scan" ? "project_scan" : "blank"; + } + + function normalizeTelemetrySharingScope(value: CompanySkillSharingScope | string | null | undefined): CompanySkillSharingScope { + return value === "private" || value === "public_link" ? value : "company"; + } + + function deriveVersionFileType(fileInventory: Array>) { + const kinds = new Set(fileInventory.map((entry) => entry.kind)); + return kinds.size === 1 ? [...kinds][0]! : "other"; + } + function firstQueryString(value: unknown): string | undefined { if (typeof value === "string") return value; if (Array.isArray(value) && typeof value[0] === "string") return value[0]; @@ -554,6 +583,13 @@ export function companySkillRoutes(db: Db) { issueId: result.issueId, }, }); + emitSkillTelemetry((client) => trackSkillTestRun(client, { + skill_id: result.skillId, + status: result.status, + run_source: req.body.skillVersionId ? "rerun" : "run", + ad_hoc: result.inputId === null, + template_used: Boolean(result.templateId), + })); res.status(201).json(result); }, ); @@ -657,6 +693,11 @@ export function companySkillRoutes(db: Db) { label: result.label, }, }); + emitSkillTelemetry((client) => trackSkillVersionSaved(client, { + skill_id: skillId, + revision_number: result.revisionNumber, + file_type: deriveVersionFileType(result.fileInventory), + })); res.status(201).json(result); }, ); @@ -729,6 +770,13 @@ export function companySkillRoutes(db: Db) { reassignedAgentIds: result.reassignments.map((entry: { agentId: string }) => entry.agentId), }, }); + emitSkillTelemetry((client) => trackSkillForked(client, { + skill_id: result.skill.id, + fork_from_skill_id: result.original.id, + source_type: result.original.sourceType, + sharing_scope: normalizeTelemetrySharingScope(result.skill.sharingScope), + reassign_agent_count: result.reassignments.length, + })); res.status(201).json(result); }, ); @@ -860,6 +908,13 @@ export function companySkillRoutes(db: Db) { name: result.name, }, }); + emitSkillTelemetry((client) => trackSkillCreated(client, { + skill_id: result.id, + creation_source: deriveSkillCreationSource(result), + sharing_scope: normalizeTelemetrySharingScope(result.sharingScope), + category_count: result.categories.length, + file_count: result.fileInventory.length, + })); res.status(201).json(result); }, @@ -925,6 +980,15 @@ export function companySkillRoutes(db: Db) { markdown: result.markdown, }, }); + const updatedSkill = await svc.detail(companyId, skillId, skillActor(req)); + const currentVersion = updatedSkill?.currentVersion ?? null; + if (currentVersion) { + emitSkillTelemetry((client) => trackSkillVersionSaved(client, { + skill_id: skillId, + revision_number: currentVersion.revisionNumber, + file_type: result.kind, + })); + } res.json(result); },