Merge pull request #2527 from paperclipai/PAP-806-telemetry-implementation-in-paperclip-plan
Add app, server, and plugin telemetry plumbing
This commit is contained in:
commit
36376968af
48 changed files with 1165 additions and 45 deletions
13
README.md
13
README.md
|
|
@ -257,6 +257,19 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
|||
|
||||
Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip)
|
||||
|
||||
## Telemetry
|
||||
|
||||
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
|
||||
|
||||
Telemetry is **enabled by default** and can be disabled with any of the following:
|
||||
|
||||
| Method | How |
|
||||
|---|---|
|
||||
| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` |
|
||||
| Standard convention | `DO_NOT_TRACK=1` |
|
||||
| CI environments | Automatically disabled when `CI=true` |
|
||||
| Config file | Set `telemetry.enabled: false` in your Paperclip config |
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ function writeBaseConfig(configPath: string) {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: { baseDir: "/tmp/paperclip-storage" },
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ function createTempConfig(): string {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ function createExistingConfigFixture() {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
117
cli/src/__tests__/telemetry.test.ts
Normal file
117
cli/src/__tests__/telemetry.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
|
||||
|
||||
function makeConfigPath(root: string, enabled: boolean): string {
|
||||
const configPath = path.join(root, ".paperclip", "config.json");
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
$meta: {
|
||||
version: 1,
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
source: "configure",
|
||||
},
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(root, "runtime", "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(root, "runtime", "backups"),
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
mode: "file",
|
||||
logDir: path.join(root, "runtime", "logs"),
|
||||
},
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: path.join(root, "runtime", "storage"),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
localEncrypted: {
|
||||
keyFilePath: path.join(root, "runtime", "secrets", "master.key"),
|
||||
},
|
||||
},
|
||||
}, null, 2));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
describe("cli telemetry", () => {
|
||||
beforeEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
for (const key of CI_ENV_VARS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true })));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("respects telemetry.enabled=false from the config file", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
const configPath = makeConfigPath(root, false);
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetryFromConfigFile } = await import("../telemetry.js");
|
||||
const client = initTelemetryFromConfigFile(configPath);
|
||||
|
||||
expect(client).toBeNull();
|
||||
expect(fs.existsSync(path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json"))).toBe(false);
|
||||
});
|
||||
|
||||
it("creates telemetry state only after the first event is tracked", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-telemetry-"));
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_INSTANCE_ID = "telemetry-test";
|
||||
|
||||
const { initTelemetry, flushTelemetry } = await import("../telemetry.js");
|
||||
const client = initTelemetry({ enabled: true });
|
||||
const statePath = path.join(root, "home", "instances", "telemetry-test", "telemetry", "state.json");
|
||||
|
||||
expect(client).not.toBeNull();
|
||||
expect(fs.existsSync(statePath)).toBe(false);
|
||||
|
||||
client!.track("install.started", { setupMode: "quickstart" });
|
||||
|
||||
expect(fs.existsSync(statePath)).toBe(true);
|
||||
|
||||
await flushTelemetry();
|
||||
});
|
||||
});
|
||||
|
|
@ -75,6 +75,9 @@ function buildSourceConfig(): PaperclipConfig {
|
|||
publicBaseUrl: "http://127.0.0.1:3100",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
CompanyPortabilityPreviewResult,
|
||||
CompanyPortabilityImportResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { getTelemetryClient, trackCompanyImported } from "../../telemetry.js";
|
||||
import { ApiRequestError } from "../../client/http.js";
|
||||
import { openUrl } from "../../client/board-auth.js";
|
||||
import { binaryContentTypeByExtension, readZipArchive } from "./zip.js";
|
||||
|
|
@ -1440,6 +1441,12 @@ export function registerCompanyCommands(program: Command): void {
|
|||
if (!imported) {
|
||||
throw new Error("Import request returned no data.");
|
||||
}
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) {
|
||||
const isPrivate = sourcePayload.type !== "github";
|
||||
const sourceRef = sourcePayload.type === "github" ? sourcePayload.url : from;
|
||||
trackCompanyImported(tc, { sourceType: sourcePayload.type, sourceRef, isPrivate });
|
||||
}
|
||||
let companyUrl: string | undefined;
|
||||
if (!ctx.json) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,9 @@ function defaultConfig(): PaperclipConfig {
|
|||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ import {
|
|||
} from "../config/home.js";
|
||||
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import {
|
||||
getTelemetryClient,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
} from "../telemetry.js";
|
||||
|
||||
type SetupMode = "quickstart" | "advanced";
|
||||
|
||||
|
|
@ -356,6 +361,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
setupMode = setupModeChoice as SetupMode;
|
||||
}
|
||||
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackInstallStarted(tc);
|
||||
|
||||
let llm: PaperclipConfig["llm"] | undefined;
|
||||
const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv();
|
||||
let {
|
||||
|
|
@ -488,6 +496,9 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
logging,
|
||||
server,
|
||||
auth,
|
||||
telemetry: {
|
||||
enabled: true,
|
||||
},
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
|
|
@ -501,6 +512,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||
|
||||
writeConfig(config, opts.config);
|
||||
|
||||
if (tc) trackInstallCompleted(tc, {
|
||||
adapterType: server.deploymentMode,
|
||||
});
|
||||
|
||||
p.note(
|
||||
[
|
||||
`Database: ${database.mode}`,
|
||||
|
|
|
|||
|
|
@ -224,6 +224,9 @@ export function buildWorktreeConfig(input: {
|
|||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
disableSignUp: source?.auth.disableSignUp ?? false,
|
||||
},
|
||||
telemetry: {
|
||||
enabled: source?.telemetry?.enabled ?? true,
|
||||
},
|
||||
storage: {
|
||||
provider: source?.storage.provider ?? "local_disk",
|
||||
localDisk: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export {
|
|||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
authConfigSchema,
|
||||
telemetryConfigSchema,
|
||||
storageConfigSchema,
|
||||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
|
|
@ -19,10 +20,11 @@ export {
|
|||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
type AuthConfig,
|
||||
type TelemetryConfig,
|
||||
type StorageConfig,
|
||||
type StorageLocalDiskConfig,
|
||||
type StorageS3Config,
|
||||
type SecretsConfig,
|
||||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
} from "@paperclipai/shared";
|
||||
} from "../../../packages/shared/src/config-schema.js";
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
|||
import { registerFeedbackCommands } from "./commands/client/feedback.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
|
|
@ -29,7 +31,7 @@ const DATA_DIR_OPTION_HELP =
|
|||
program
|
||||
.name("paperclipai")
|
||||
.description("Paperclip CLI — setup, diagnose, and configure your instance")
|
||||
.version("0.2.7");
|
||||
.version(cliVersion);
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
|
|
@ -39,6 +41,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => {
|
|||
hasContextOption: optionNames.has("context"),
|
||||
});
|
||||
loadPaperclipEnvFile(options.config);
|
||||
initTelemetryFromConfigFile(options.config);
|
||||
});
|
||||
|
||||
program
|
||||
|
|
@ -156,7 +159,20 @@ auth
|
|||
|
||||
registerClientAuthCommands(auth);
|
||||
|
||||
program.parseAsync().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
async function main(): Promise<void> {
|
||||
let failed = false;
|
||||
try {
|
||||
await program.parseAsync();
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
await flushTelemetry();
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
|
|
|||
49
cli/src/telemetry.ts
Normal file
49
cli/src/telemetry.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import path from "node:path";
|
||||
import {
|
||||
TelemetryClient,
|
||||
resolveTelemetryConfig,
|
||||
loadOrCreateState,
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
} from "../../packages/shared/src/telemetry/index.js";
|
||||
import { resolvePaperclipInstanceRoot } from "./config/home.js";
|
||||
import { readConfig } from "./config/store.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
|
||||
let client: TelemetryClient | null = null;
|
||||
|
||||
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
|
||||
if (client) return client;
|
||||
|
||||
const config = resolveTelemetryConfig(fileConfig);
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
|
||||
client = new TelemetryClient(config, () => loadOrCreateState(stateDir, cliVersion), cliVersion);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function initTelemetryFromConfigFile(configPath?: string): TelemetryClient | null {
|
||||
try {
|
||||
return initTelemetry(readConfig(configPath)?.telemetry);
|
||||
} catch {
|
||||
return initTelemetry();
|
||||
}
|
||||
}
|
||||
|
||||
export function getTelemetryClient(): TelemetryClient | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function flushTelemetry(): Promise<void> {
|
||||
if (client) {
|
||||
await client.flush();
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
};
|
||||
10
cli/src/version.ts
Normal file
10
cli/src/version.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { createRequire } from "node:module";
|
||||
|
||||
type PackageJson = {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../package.json") as PackageJson;
|
||||
|
||||
export const cliVersion = pkg.version ?? "0.0.0";
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
"rootDir": ".."
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../packages/shared/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,4 +241,71 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
|
|||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0047 safely when feedback tables and run columns already exist",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const overjoyedGrootHash = await migrationHash("0047_overjoyed_groot.sql");
|
||||
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${overjoyedGrootHash}'`,
|
||||
);
|
||||
|
||||
const tables = await sql.unsafe<{ table_name: string }[]>(
|
||||
`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('feedback_exports', 'feedback_votes')
|
||||
ORDER BY table_name
|
||||
`,
|
||||
);
|
||||
expect(tables.map((row) => row.table_name)).toEqual([
|
||||
"feedback_exports",
|
||||
"feedback_votes",
|
||||
]);
|
||||
|
||||
const columns = await sql.unsafe<{ table_name: string; column_name: string }[]>(
|
||||
`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND (
|
||||
(table_name = 'companies' AND column_name IN (
|
||||
'feedback_data_sharing_enabled',
|
||||
'feedback_data_sharing_consent_at',
|
||||
'feedback_data_sharing_consent_by_user_id',
|
||||
'feedback_data_sharing_terms_version'
|
||||
))
|
||||
OR (table_name = 'document_revisions' AND column_name = 'created_by_run_id')
|
||||
OR (table_name = 'issue_comments' AND column_name = 'created_by_run_id')
|
||||
)
|
||||
ORDER BY table_name, column_name
|
||||
`,
|
||||
);
|
||||
expect(columns).toHaveLength(6);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0047_overjoyed_groot.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
CREATE TABLE "feedback_exports" (
|
||||
CREATE TABLE IF NOT EXISTS "feedback_exports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"feedback_vote_id" uuid NOT NULL,
|
||||
|
|
@ -27,7 +27,7 @@ CREATE TABLE "feedback_exports" (
|
|||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feedback_votes" (
|
||||
CREATE TABLE IF NOT EXISTS "feedback_votes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
|
|
@ -44,27 +44,59 @@ CREATE TABLE "feedback_votes" (
|
|||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_consent_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_consent_by_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN "feedback_data_sharing_terms_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD COLUMN "created_by_run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD COLUMN "created_by_run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_feedback_vote_id_feedback_votes_id_fk" FOREIGN KEY ("feedback_vote_id") REFERENCES "public"."feedback_votes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "feedback_exports_feedback_vote_idx" ON "feedback_exports" USING btree ("feedback_vote_id");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_exports_company_created_idx" ON "feedback_exports" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_exports_company_status_idx" ON "feedback_exports" USING btree ("company_id","status","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_exports_company_issue_idx" ON "feedback_exports" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_exports_company_project_idx" ON "feedback_exports" USING btree ("company_id","project_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_exports_company_author_idx" ON "feedback_exports" USING btree ("company_id","author_user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_votes_company_issue_idx" ON "feedback_votes" USING btree ("company_id","issue_id");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_votes_issue_target_idx" ON "feedback_votes" USING btree ("issue_id","target_type","target_id");--> statement-breakpoint
|
||||
CREATE INDEX "feedback_votes_author_idx" ON "feedback_votes" USING btree ("author_user_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "feedback_votes_company_target_author_idx" ON "feedback_votes" USING btree ("company_id","target_type","target_id","author_user_id");--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "feedback_data_sharing_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "feedback_data_sharing_consent_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "feedback_data_sharing_consent_by_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "companies" ADD COLUMN IF NOT EXISTS "feedback_data_sharing_terms_version" text;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD COLUMN IF NOT EXISTS "created_by_run_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "created_by_run_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_exports_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_exports_feedback_vote_id_feedback_votes_id_fk') THEN
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_feedback_vote_id_feedback_votes_id_fk" FOREIGN KEY ("feedback_vote_id") REFERENCES "public"."feedback_votes"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_exports_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_exports_project_id_projects_id_fk') THEN
|
||||
ALTER TABLE "feedback_exports" ADD CONSTRAINT "feedback_exports_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_votes_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'feedback_votes_issue_id_issues_id_fk') THEN
|
||||
ALTER TABLE "feedback_votes" ADD CONSTRAINT "feedback_votes_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_exports_feedback_vote_idx" ON "feedback_exports" USING btree ("feedback_vote_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_exports_company_created_idx" ON "feedback_exports" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_exports_company_status_idx" ON "feedback_exports" USING btree ("company_id","status","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_exports_company_issue_idx" ON "feedback_exports" USING btree ("company_id","issue_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_exports_company_project_idx" ON "feedback_exports" USING btree ("company_id","project_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_exports_company_author_idx" ON "feedback_exports" USING btree ("company_id","author_user_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_votes_company_issue_idx" ON "feedback_votes" USING btree ("company_id","issue_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_votes_issue_target_idx" ON "feedback_votes" USING btree ("issue_id","target_type","target_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "feedback_votes_author_idx" ON "feedback_votes" USING btree ("author_user_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "feedback_votes_company_target_author_idx" ON "feedback_votes" USING btree ("company_id","target_type","target_id","author_user_id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'document_revisions_created_by_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_comments_created_by_run_id_heartbeat_runs_id_fk') THEN
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||
"goals.update",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"telemetry.track",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"events.subscribe",
|
||||
|
|
|
|||
|
|
@ -405,6 +405,16 @@ async function registerActionHandlers(ctx: PluginContext): Promise<void> {
|
|||
data: { companyId },
|
||||
});
|
||||
await ctx.metrics.write("demo.events.emitted", 1, { source: "manual" });
|
||||
await ctx.telemetry.track("demo_event", {
|
||||
source: "manual",
|
||||
has_company: Boolean(companyId),
|
||||
});
|
||||
pushRecord({
|
||||
level: "info",
|
||||
source: "telemetry",
|
||||
message: "Tracked plugin telemetry event demo_event",
|
||||
data: { companyId },
|
||||
});
|
||||
return { ok: true, message };
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ Declare in `manifest.capabilities`. Grouped by scope:
|
|||
| | `issue.comments.create` |
|
||||
| | `activity.log.write` |
|
||||
| | `metrics.write` |
|
||||
| | `telemetry.track` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
| | `plugin.state.read` |
|
||||
| | `plugin.state.write` |
|
||||
|
|
|
|||
|
|
@ -135,6 +135,11 @@ export interface HostServices {
|
|||
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `telemetry.track`. */
|
||||
telemetry: {
|
||||
track(params: WorkerToHostMethods["telemetry.track"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `log`. */
|
||||
logger: {
|
||||
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||
|
|
@ -284,6 +289,9 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
|
|||
// Metrics
|
||||
"metrics.write": "metrics.write",
|
||||
|
||||
// Telemetry
|
||||
"telemetry.track": "telemetry.track",
|
||||
|
||||
// Logger — always allowed
|
||||
"log": null,
|
||||
|
||||
|
|
@ -447,6 +455,11 @@ export function createHostClientHandlers(
|
|||
return services.metrics.write(params);
|
||||
}),
|
||||
|
||||
// Telemetry
|
||||
"telemetry.track": gated("telemetry.track", async (params) => {
|
||||
return services.telemetry.track(params);
|
||||
}),
|
||||
|
||||
// Logger
|
||||
"log": gated("log", async (params) => {
|
||||
return services.logger.log(params);
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export type {
|
|||
PluginStreamsClient,
|
||||
PluginToolsClient,
|
||||
PluginMetricsClient,
|
||||
PluginTelemetryClient,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -519,6 +519,12 @@ export interface WorkerToHostMethods {
|
|||
result: void,
|
||||
];
|
||||
|
||||
// Telemetry
|
||||
"telemetry.track": [
|
||||
params: { eventName: string; dimensions?: Record<string, string | number | boolean> },
|
||||
result: void,
|
||||
];
|
||||
|
||||
// Logger
|
||||
"log": [
|
||||
params: { level: "info" | "warn" | "error" | "debug"; message: string; meta?: Record<string, unknown> },
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface TestHarness {
|
|||
logs: TestHarnessLogEntry[];
|
||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||
telemetry: Array<{ eventName: string; dimensions?: Record<string, string | number | boolean> }>;
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
|
|
@ -132,6 +133,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
const logs: TestHarnessLogEntry[] = [];
|
||||
const activity: TestHarness["activity"] = [];
|
||||
const metrics: TestHarness["metrics"] = [];
|
||||
const telemetry: TestHarness["telemetry"] = [];
|
||||
|
||||
const state = new Map<string, unknown>();
|
||||
const entities = new Map<string, PluginEntityRecord>();
|
||||
|
|
@ -631,6 +633,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
metrics.push({ name, value, tags });
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
async track(eventName, dimensions) {
|
||||
requireCapability(manifest, capabilitySet, "telemetry.track");
|
||||
telemetry.push({ eventName, dimensions });
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info(message, meta) {
|
||||
logs.push({ level: "info", message, meta });
|
||||
|
|
@ -729,6 +737,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
logs,
|
||||
activity,
|
||||
metrics,
|
||||
telemetry,
|
||||
};
|
||||
|
||||
return harness;
|
||||
|
|
|
|||
|
|
@ -761,6 +761,28 @@ export interface PluginMetricsClient {
|
|||
write(name: string, value: number, tags?: Record<string, string>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.telemetry` — emit plugin-scoped telemetry to the host's external
|
||||
* telemetry pipeline.
|
||||
*
|
||||
* Requires `telemetry.track` capability.
|
||||
*/
|
||||
export interface PluginTelemetryClient {
|
||||
/**
|
||||
* Track a plugin telemetry event.
|
||||
*
|
||||
* The host prefixes the final event name as `plugin.<pluginId>.<eventName>`
|
||||
* before forwarding it to the shared telemetry client.
|
||||
*
|
||||
* @param eventName - Bare plugin event slug (for example `"sync_completed"`)
|
||||
* @param dimensions - Optional structured dimensions
|
||||
*/
|
||||
track(
|
||||
eventName: string,
|
||||
dimensions?: Record<string, string | number | boolean>,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.companies` — read company metadata.
|
||||
*
|
||||
|
|
@ -1156,6 +1178,9 @@ export interface PluginContext {
|
|||
/** Write plugin metrics. Requires `metrics.write`. */
|
||||
metrics: PluginMetricsClient;
|
||||
|
||||
/** Emit plugin-scoped external telemetry. Requires `telemetry.track`. */
|
||||
telemetry: PluginTelemetryClient;
|
||||
|
||||
/** Structured logger. Output is captured and surfaced in the plugin health dashboard. */
|
||||
logger: PluginLogger;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -793,6 +793,15 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
async track(
|
||||
eventName: string,
|
||||
dimensions?: Record<string, string | number | boolean>,
|
||||
): Promise<void> {
|
||||
await callHost("telemetry.track", { eventName, dimensions });
|
||||
},
|
||||
},
|
||||
|
||||
logger: {
|
||||
info(message: string, meta?: Record<string, unknown>): void {
|
||||
notifyHost("log", { level: "info", message, meta });
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./telemetry": "./src/telemetry/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
|
@ -23,6 +24,10 @@
|
|||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./telemetry": {
|
||||
"types": "./dist/telemetry/index.d.ts",
|
||||
"import": "./dist/telemetry/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"import": "./dist/*.js"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ export const secretsConfigSchema = z.object({
|
|||
}),
|
||||
});
|
||||
|
||||
export const telemetryConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
}).default({});
|
||||
|
||||
export const paperclipConfigSchema = z
|
||||
.object({
|
||||
$meta: configMetaSchema,
|
||||
|
|
@ -102,6 +106,7 @@ export const paperclipConfigSchema = z
|
|||
database: databaseConfigSchema,
|
||||
logging: loggingConfigSchema,
|
||||
server: serverConfigSchema,
|
||||
telemetry: telemetryConfigSchema,
|
||||
auth: authConfigSchema.default({
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
|
|
@ -174,5 +179,6 @@ export type StorageS3Config = z.infer<typeof storageS3ConfigSchema>;
|
|||
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
|
||||
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
|
||||
export type AuthConfig = z.infer<typeof authConfigSchema>;
|
||||
export type TelemetryConfig = z.infer<typeof telemetryConfigSchema>;
|
||||
export type ConfigMeta = z.infer<typeof configMetaSchema>;
|
||||
export type DatabaseBackupConfig = z.infer<typeof databaseBackupConfigSchema>;
|
||||
|
|
|
|||
|
|
@ -448,6 +448,7 @@ export const PLUGIN_CAPABILITIES = [
|
|||
"agent.sessions.close",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"telemetry.track",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
|
|
|
|||
|
|
@ -611,6 +611,8 @@ export {
|
|||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
telemetryConfigSchema,
|
||||
type TelemetryConfig,
|
||||
type PaperclipConfig,
|
||||
type LlmConfig,
|
||||
type DatabaseBackupConfig,
|
||||
|
|
|
|||
104
packages/shared/src/telemetry/client.ts
Normal file
104
packages/shared/src/telemetry/client.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
TelemetryConfig,
|
||||
TelemetryEvent,
|
||||
TelemetryEventName,
|
||||
TelemetryState,
|
||||
} from "./types.js";
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest";
|
||||
const BATCH_SIZE = 50;
|
||||
const SEND_TIMEOUT_MS = 5_000;
|
||||
|
||||
export class TelemetryClient {
|
||||
private queue: TelemetryEvent[] = [];
|
||||
private readonly config: TelemetryConfig;
|
||||
private readonly stateFactory: () => TelemetryState;
|
||||
private readonly version: string;
|
||||
private state: TelemetryState | null = null;
|
||||
private flushInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(config: TelemetryConfig, stateFactory: () => TelemetryState, version: string) {
|
||||
this.config = config;
|
||||
this.stateFactory = stateFactory;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
track(eventName: TelemetryEventName, dimensions?: Record<string, string | number | boolean>): void {
|
||||
if (!this.config.enabled) return;
|
||||
this.getState(); // ensure state is initialised (side-effect: creates state file on first call)
|
||||
|
||||
this.queue.push({
|
||||
name: eventName,
|
||||
occurredAt: new Date().toISOString(),
|
||||
dimensions: dimensions ?? {},
|
||||
});
|
||||
|
||||
if (this.queue.length >= BATCH_SIZE) {
|
||||
void this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (!this.config.enabled || this.queue.length === 0) return;
|
||||
|
||||
const events = this.queue.splice(0);
|
||||
const state = this.getState();
|
||||
const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT;
|
||||
const app = this.config.app ?? "paperclip";
|
||||
const schemaVersion = this.config.schemaVersion ?? "1";
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS);
|
||||
try {
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
app,
|
||||
schemaVersion,
|
||||
installId: state.installId,
|
||||
events,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch {
|
||||
// Fire-and-forget: silent failure, no retries
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicFlush(intervalMs: number = 60_000): void {
|
||||
if (this.flushInterval) return;
|
||||
this.flushInterval = setInterval(() => {
|
||||
void this.flush();
|
||||
}, intervalMs);
|
||||
// Allow the process to exit even if the interval is still active
|
||||
if (typeof this.flushInterval === "object" && "unref" in this.flushInterval) {
|
||||
this.flushInterval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.flushInterval) {
|
||||
clearInterval(this.flushInterval);
|
||||
this.flushInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
hashPrivateRef(value: string): string {
|
||||
const state = this.getState();
|
||||
return createHash("sha256")
|
||||
.update(state.salt + value)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
private getState(): TelemetryState {
|
||||
if (!this.state) {
|
||||
this.state = this.stateFactory();
|
||||
}
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
25
packages/shared/src/telemetry/config.ts
Normal file
25
packages/shared/src/telemetry/config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { TelemetryConfig } from "./types.js";
|
||||
|
||||
const CI_ENV_VARS = ["CI", "CONTINUOUS_INTEGRATION", "BUILD_NUMBER", "GITHUB_ACTIONS", "GITLAB_CI"];
|
||||
|
||||
function isCI(): boolean {
|
||||
return CI_ENV_VARS.some((key) => process.env[key] === "true" || process.env[key] === "1");
|
||||
}
|
||||
|
||||
export function resolveTelemetryConfig(fileConfig?: { enabled?: boolean }): TelemetryConfig {
|
||||
if (process.env.PAPERCLIP_TELEMETRY_DISABLED === "1") {
|
||||
return { enabled: false };
|
||||
}
|
||||
if (process.env.DO_NOT_TRACK === "1") {
|
||||
return { enabled: false };
|
||||
}
|
||||
if (isCI()) {
|
||||
return { enabled: false };
|
||||
}
|
||||
if (fileConfig?.enabled === false) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
const endpoint = process.env.PAPERCLIP_TELEMETRY_ENDPOINT || undefined;
|
||||
return { enabled: true, endpoint };
|
||||
}
|
||||
45
packages/shared/src/telemetry/events.ts
Normal file
45
packages/shared/src/telemetry/events.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { TelemetryClient } from "./client.js";
|
||||
|
||||
export function trackInstallStarted(client: TelemetryClient): void {
|
||||
client.track("install.started");
|
||||
}
|
||||
|
||||
export function trackInstallCompleted(
|
||||
client: TelemetryClient,
|
||||
dims: { adapterType: string },
|
||||
): void {
|
||||
client.track("install.completed", { adapter_type: dims.adapterType });
|
||||
}
|
||||
|
||||
export function trackCompanyImported(
|
||||
client: TelemetryClient,
|
||||
dims: { sourceType: string; sourceRef: string; isPrivate: boolean },
|
||||
): void {
|
||||
const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef;
|
||||
client.track("company.imported", {
|
||||
source_type: dims.sourceType,
|
||||
source_ref: ref,
|
||||
source_ref_hashed: dims.isPrivate,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackAgentFirstHeartbeat(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
): void {
|
||||
client.track("agent.first_heartbeat", { agent_role: dims.agentRole });
|
||||
}
|
||||
|
||||
export function trackAgentTaskCompleted(
|
||||
client: TelemetryClient,
|
||||
dims: { agentRole: string },
|
||||
): void {
|
||||
client.track("agent.task_completed", { agent_role: dims.agentRole });
|
||||
}
|
||||
|
||||
export function trackErrorHandlerCrash(
|
||||
client: TelemetryClient,
|
||||
dims: { errorCode: string },
|
||||
): void {
|
||||
client.track("error.handler_crash", { error_code: dims.errorCode });
|
||||
}
|
||||
18
packages/shared/src/telemetry/index.ts
Normal file
18
packages/shared/src/telemetry/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export { TelemetryClient } from "./client.js";
|
||||
export { resolveTelemetryConfig } from "./config.js";
|
||||
export { loadOrCreateState } from "./state.js";
|
||||
export {
|
||||
trackInstallStarted,
|
||||
trackInstallCompleted,
|
||||
trackCompanyImported,
|
||||
trackAgentFirstHeartbeat,
|
||||
trackAgentTaskCompleted,
|
||||
trackErrorHandlerCrash,
|
||||
} from "./events.js";
|
||||
export type {
|
||||
TelemetryConfig,
|
||||
TelemetryState,
|
||||
TelemetryEvent,
|
||||
TelemetryEventEnvelope,
|
||||
TelemetryEventName,
|
||||
} from "./types.js";
|
||||
31
packages/shared/src/telemetry/state.ts
Normal file
31
packages/shared/src/telemetry/state.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { randomUUID, randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { TelemetryState } from "./types.js";
|
||||
|
||||
export function loadOrCreateState(stateDir: string, version: string): TelemetryState {
|
||||
const filePath = path.join(stateDir, "state.json");
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as TelemetryState;
|
||||
if (parsed.installId && parsed.salt) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted state file — recreate
|
||||
}
|
||||
}
|
||||
|
||||
const state: TelemetryState = {
|
||||
installId: randomUUID(),
|
||||
salt: randomBytes(32).toString("hex"),
|
||||
createdAt: new Date().toISOString(),
|
||||
firstSeenVersion: version,
|
||||
};
|
||||
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
writeFileSync(filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
||||
return state;
|
||||
}
|
||||
37
packages/shared/src/telemetry/types.ts
Normal file
37
packages/shared/src/telemetry/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export interface TelemetryState {
|
||||
installId: string;
|
||||
salt: string;
|
||||
createdAt: string;
|
||||
firstSeenVersion: string;
|
||||
}
|
||||
|
||||
export interface TelemetryConfig {
|
||||
enabled: boolean;
|
||||
endpoint?: string;
|
||||
app?: string;
|
||||
schemaVersion?: string;
|
||||
}
|
||||
|
||||
/** Per-event object inside the backend envelope */
|
||||
export interface TelemetryEvent {
|
||||
name: string;
|
||||
occurredAt: string;
|
||||
dimensions: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
/** Full payload sent to the backend ingest endpoint */
|
||||
export interface TelemetryEventEnvelope {
|
||||
app: string;
|
||||
schemaVersion: string;
|
||||
installId: string;
|
||||
events: TelemetryEvent[];
|
||||
}
|
||||
|
||||
export type TelemetryEventName =
|
||||
| "install.started"
|
||||
| "install.completed"
|
||||
| "company.imported"
|
||||
| "agent.first_heartbeat"
|
||||
| "agent.task_completed"
|
||||
| "error.handler_crash"
|
||||
| `plugin.${string}`;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
|
|
@ -16,6 +16,23 @@ import {
|
|||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
|
||||
const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.ts", () => ({
|
||||
getTelemetryClient: () => mockTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/shared/telemetry")>(
|
||||
"@paperclipai/shared/telemetry",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
trackAgentFirstHeartbeat: mockTrackAgentFirstHeartbeat,
|
||||
};
|
||||
});
|
||||
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -43,6 +60,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
runningProcesses.clear();
|
||||
for (const child of childProcesses) {
|
||||
child.kill("SIGKILL");
|
||||
|
|
@ -67,6 +85,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
|
||||
async function seedRunFixture(input?: {
|
||||
adapterType?: string;
|
||||
agentStatus?: "paused" | "idle" | "running";
|
||||
runStatus?: "running" | "queued" | "failed";
|
||||
processPid?: number | null;
|
||||
processLossRetryCount?: number;
|
||||
|
|
@ -94,7 +113,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "paused",
|
||||
status: input?.agentStatus ?? "paused",
|
||||
adapterType: input?.adapterType ?? "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
|
|
@ -252,4 +271,18 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
expect(run?.errorCode).toBeNull();
|
||||
expect(run?.error).toBeNull();
|
||||
});
|
||||
|
||||
it("tracks the first heartbeat with the agent role instead of adapter type", async () => {
|
||||
const { runId } = await seedRunFixture({
|
||||
agentStatus: "running",
|
||||
includeIssue: false,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
await heartbeat.cancelRun(runId);
|
||||
|
||||
expect(mockTrackAgentFirstHeartbeat).toHaveBeenCalledWith(mockTelemetryClient, {
|
||||
agentRole: "engineer",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
125
server/src/__tests__/issue-telemetry-routes.test.ts
Normal file
125
server/src/__tests__/issue-telemetry-routes.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentTaskCompleted: mockTrackAgentTaskCompleted,
|
||||
}));
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
feedbackService: () => ({}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
}),
|
||||
instanceSettingsService: () => ({}),
|
||||
issueApprovalService: () => ({}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
function makeIssue(status: "todo" | "done") {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
status,
|
||||
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1018",
|
||||
title: "Telemetry test",
|
||||
};
|
||||
}
|
||||
|
||||
function createApp(actor: Record<string, unknown>) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, {} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("issue telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));
|
||||
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...makeIssue("todo"),
|
||||
...patch,
|
||||
}));
|
||||
});
|
||||
|
||||
it("emits task-completed telemetry with the agent role", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
role: "engineer",
|
||||
adapterType: "codex_local",
|
||||
});
|
||||
|
||||
const res = await request(createApp({
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
runId: null,
|
||||
}))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ status: "done" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockTrackAgentTaskCompleted).toHaveBeenCalledWith(expect.anything(), {
|
||||
agentRole: "engineer",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not emit agent task-completed telemetry for board-driven completions", async () => {
|
||||
const res = await request(createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: false,
|
||||
}))
|
||||
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||
.send({ status: "done" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockTrackAgentTaskCompleted).not.toHaveBeenCalled();
|
||||
expect(mockAgentService.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
114
server/src/__tests__/plugin-telemetry-bridge.test.ts
Normal file
114
server/src/__tests__/plugin-telemetry-bridge.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js";
|
||||
import { buildHostServices } from "../services/plugin-host-services.js";
|
||||
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../telemetry.js", () => ({
|
||||
getTelemetryClient: mockGetTelemetryClient,
|
||||
}));
|
||||
|
||||
function createEventBusStub() {
|
||||
return {
|
||||
forPlugin() {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
};
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("plugin telemetry bridge", () => {
|
||||
beforeEach(() => {
|
||||
mockGetTelemetryClient.mockReset();
|
||||
});
|
||||
|
||||
it("prefixes plugin telemetry events before forwarding them to the telemetry client", async () => {
|
||||
const track = vi.fn();
|
||||
mockGetTelemetryClient.mockReturnValue({ track });
|
||||
|
||||
const services = buildHostServices(
|
||||
{} as never,
|
||||
"plugin-record-id",
|
||||
"linear",
|
||||
createEventBusStub(),
|
||||
);
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "linear",
|
||||
capabilities: ["telemetry.track"],
|
||||
services,
|
||||
});
|
||||
|
||||
await handlers["telemetry.track"]({
|
||||
eventName: "sync_completed",
|
||||
dimensions: { attempts: 2, success: true },
|
||||
});
|
||||
|
||||
expect(track).toHaveBeenCalledWith("plugin.linear.sync_completed", {
|
||||
attempts: 2,
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid bare telemetry event names before prefixing", async () => {
|
||||
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
|
||||
|
||||
const services = buildHostServices(
|
||||
{} as never,
|
||||
"plugin-record-id",
|
||||
"linear",
|
||||
createEventBusStub(),
|
||||
);
|
||||
|
||||
await expect(
|
||||
services.telemetry.track({ eventName: "sync.completed" }),
|
||||
).rejects.toThrow(
|
||||
'Plugin telemetry event names must be lowercase slugs using letters, numbers, "_" or "-".',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects telemetry tracking when the plugin lacks the capability", async () => {
|
||||
const services = buildHostServices(
|
||||
{} as never,
|
||||
"plugin-record-id",
|
||||
"linear",
|
||||
createEventBusStub(),
|
||||
);
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "linear",
|
||||
capabilities: [],
|
||||
services,
|
||||
});
|
||||
|
||||
await expect(
|
||||
handlers["telemetry.track"]({ eventName: "sync_completed" }),
|
||||
).rejects.toMatchObject({
|
||||
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
|
||||
});
|
||||
|
||||
expect(mockGetTelemetryClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes telemetry requests through when the plugin declares the capability", async () => {
|
||||
const services = buildHostServices(
|
||||
{} as never,
|
||||
"plugin-record-id",
|
||||
"linear",
|
||||
createEventBusStub(),
|
||||
);
|
||||
const handlers = createHostClientHandlers({
|
||||
pluginId: "linear",
|
||||
capabilities: ["telemetry.track"],
|
||||
services,
|
||||
});
|
||||
|
||||
await handlers["telemetry.track"]({
|
||||
eventName: "sync_completed",
|
||||
dimensions: { source: "manual" },
|
||||
});
|
||||
|
||||
expect(mockGetTelemetryClient).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
66
server/src/__tests__/telemetry-client-flush.test.ts
Normal file
66
server/src/__tests__/telemetry-client-flush.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { TelemetryClient } from "../../../packages/shared/src/telemetry/client.js";
|
||||
import type { TelemetryConfig, TelemetryState } from "../../../packages/shared/src/telemetry/types.js";
|
||||
|
||||
function makeClient(config?: Partial<TelemetryConfig>) {
|
||||
const merged: TelemetryConfig = { enabled: true, endpoint: "http://localhost:9999/ingest", ...config };
|
||||
const state: TelemetryState = {
|
||||
installId: "test-install",
|
||||
salt: "test-salt",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
firstSeenVersion: "0.0.0",
|
||||
};
|
||||
return new TelemetryClient(merged, () => state, "0.0.0-test");
|
||||
}
|
||||
|
||||
describe("TelemetryClient periodic flush", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("flushes queued events on interval", async () => {
|
||||
const client = makeClient();
|
||||
client.startPeriodicFlush(1000);
|
||||
|
||||
client.track("install.started");
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second tick with no new events — no additional call
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// New event gets flushed on next tick
|
||||
client.track("install.started");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
client.stop();
|
||||
});
|
||||
|
||||
it("stop() prevents further flushes", async () => {
|
||||
const client = makeClient();
|
||||
client.startPeriodicFlush(1000);
|
||||
|
||||
client.track("install.started");
|
||||
client.stop();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("startPeriodicFlush is idempotent", () => {
|
||||
const client = makeClient();
|
||||
client.startPeriodicFlush(1000);
|
||||
client.startPeriodicFlush(1000); // should not throw or double-fire
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
|
|
@ -75,6 +75,7 @@ export interface Config {
|
|||
heartbeatSchedulerEnabled: boolean;
|
||||
heartbeatSchedulerIntervalMs: number;
|
||||
companyDeletionEnabled: boolean;
|
||||
telemetryEnabled: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
|
|
@ -267,5 +268,6 @@ export function loadConfig(): Config {
|
|||
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
||||
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
||||
companyDeletionEnabled,
|
||||
telemetryEnabled: fileConfig?.telemetry?.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { createStorageServiceFromConfig } from "./storage/index.js";
|
|||
import { printStartupBanner } from "./startup-banner.js";
|
||||
import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js";
|
||||
import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
|
||||
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
|
||||
|
||||
type BetterAuthSessionUser = {
|
||||
id: string;
|
||||
|
|
@ -79,6 +80,7 @@ export interface StartedServer {
|
|||
|
||||
export async function startServer(): Promise<StartedServer> {
|
||||
let config = loadConfig();
|
||||
initTelemetry({ enabled: config.telemetryEnabled });
|
||||
if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) {
|
||||
process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider;
|
||||
}
|
||||
|
|
@ -726,18 +728,26 @@ export async function startServer(): Promise<StartedServer> {
|
|||
});
|
||||
});
|
||||
|
||||
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
||||
{
|
||||
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
|
||||
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
||||
try {
|
||||
await embeddedPostgres?.stop();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
||||
} finally {
|
||||
process.exit(0);
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
telemetryClient.stop();
|
||||
await telemetryClient.flush();
|
||||
}
|
||||
|
||||
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
||||
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
||||
try {
|
||||
await embeddedPostgres?.stop();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
||||
process.once("SIGINT", () => {
|
||||
void shutdown("SIGINT");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import type { Request, Response, NextFunction } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
export interface ErrorContext {
|
||||
error: { message: string; stack?: string; name?: string; details?: unknown; raw?: unknown };
|
||||
|
|
@ -44,6 +46,8 @@ export function errorHandler(
|
|||
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
|
||||
err,
|
||||
);
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackErrorHandlerCrash(tc, { errorCode: err.name });
|
||||
}
|
||||
res.status(err.status).json({
|
||||
error: err.message,
|
||||
|
|
@ -67,5 +71,8 @@ export function errorHandler(
|
|||
rootError,
|
||||
);
|
||||
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackErrorHandlerCrash(tc, { errorCode: rootError.name });
|
||||
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
|
|
@ -1177,6 +1179,16 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||
},
|
||||
});
|
||||
|
||||
if (issue.status === "done" && existing.status !== "done") {
|
||||
const tc = getTelemetryClient();
|
||||
if (tc && actor.agentId) {
|
||||
const actorAgent = await agentsSvc.getById(actor.agentId);
|
||||
if (actorAgent) {
|
||||
trackAgentTaskCompleted(tc, { agentRole: actorAgent.role });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let comment = null;
|
||||
if (commentBody) {
|
||||
comment = await svc.addComment(id, commentBody, {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
|
|||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
|
||||
import { costService } from "./costs.js";
|
||||
import { trackAgentFirstHeartbeat } from "@paperclipai/shared/telemetry";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import { companySkillService } from "./company-skills.js";
|
||||
import { budgetService, type BudgetEnforcementScope } from "./budgets.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
|
@ -1807,6 +1809,8 @@ export function heartbeatService(db: Db) {
|
|||
return;
|
||||
}
|
||||
|
||||
const isFirstHeartbeat = !existing.lastHeartbeatAt;
|
||||
|
||||
const runningCount = await countRunningRunsForAgent(agentId);
|
||||
const nextStatus =
|
||||
runningCount > 0
|
||||
|
|
@ -1826,6 +1830,11 @@ export function heartbeatService(db: Db) {
|
|||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (isFirstHeartbeat && updated) {
|
||||
const tc = getTelemetryClient();
|
||||
if (tc) trackAgentFirstHeartbeat(tc, { agentRole: updated.role });
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
publishLiveEvent({
|
||||
companyId: updated.companyId,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
|
|||
"issue.comments.create": ["issue.comments.create"],
|
||||
"activity.log": ["activity.log.write"],
|
||||
"metrics.write": ["metrics.write"],
|
||||
"telemetry.track": ["telemetry.track"],
|
||||
|
||||
// Plugin state operations
|
||||
"plugin.state.get": ["plugin.state.read"],
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { request as httpRequest } from "node:http";
|
|||
import { request as httpsRequest } from "node:https";
|
||||
import { isIP } from "node:net";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSRF protection for plugin HTTP fetch
|
||||
|
|
@ -47,6 +48,7 @@ const DNS_LOOKUP_TIMEOUT_MS = 5_000;
|
|||
|
||||
/** Only these protocols are allowed for plugin HTTP requests. */
|
||||
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
const TELEMETRY_EVENT_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
/**
|
||||
* Check if an IP address is in a private/reserved range (RFC 1918, loopback,
|
||||
|
|
@ -636,6 +638,20 @@ export function buildHostServices(
|
|||
},
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
async track(params) {
|
||||
const eventName = String(params.eventName ?? "").trim();
|
||||
if (!TELEMETRY_EVENT_NAME_REGEX.test(eventName)) {
|
||||
throw new Error(
|
||||
'Plugin telemetry event names must be lowercase slugs using letters, numbers, "_" or "-".',
|
||||
);
|
||||
}
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (!telemetryClient) return;
|
||||
telemetryClient.track(`plugin.${pluginKey}.${eventName}`, params.dimensions);
|
||||
},
|
||||
},
|
||||
|
||||
logger: {
|
||||
async log(params) {
|
||||
const { level, meta } = params;
|
||||
|
|
|
|||
30
server/src/telemetry.ts
Normal file
30
server/src/telemetry.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import path from "node:path";
|
||||
import {
|
||||
TelemetryClient,
|
||||
resolveTelemetryConfig,
|
||||
loadOrCreateState,
|
||||
} from "@paperclipai/shared/telemetry";
|
||||
import { resolvePaperclipInstanceRoot } from "./home-paths.js";
|
||||
import { serverVersion } from "./version.js";
|
||||
|
||||
let client: TelemetryClient | null = null;
|
||||
|
||||
export function initTelemetry(fileConfig?: { enabled?: boolean }): TelemetryClient | null {
|
||||
if (client) return client;
|
||||
|
||||
const config = resolveTelemetryConfig(fileConfig);
|
||||
if (!config.enabled) return null;
|
||||
|
||||
const stateDir = path.join(resolvePaperclipInstanceRoot(), "telemetry");
|
||||
client = new TelemetryClient(
|
||||
config,
|
||||
() => loadOrCreateState(stateDir, serverVersion),
|
||||
serverVersion,
|
||||
);
|
||||
client.startPeriodicFlush(60_000);
|
||||
return client;
|
||||
}
|
||||
|
||||
export function getTelemetryClient(): TelemetryClient | null {
|
||||
return client;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue