mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
fix: address skill telemetry review
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
7 files changed
+208
-189
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? "<anonymous>", payloadNode) {
|
||||
function extractWrapperDimensions(wrapper, sourceFile, wrapperName = wrapper.name?.text ?? "<anonymous>") {
|
||||
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) {
|
||||
|
||||
@@ -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 extends string | undefined> = T | (string & {});
|
||||
function asEventDimension<T extends string>(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" }),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ReturnType<typeof getTelemetryClient>>) => void) {
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) run(telemetryClient);
|
||||
}
|
||||
|
||||
function deriveSkillCreationSource(skill: { forkedFromSkillId?: string | null; metadata?: Record<string, unknown> | 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<Pick<CompanySkillFileInventoryEntry, "kind">>) {
|
||||
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);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user