diff --git a/README.md b/README.md index a24958e..d6a74f3 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,7 @@ Collector settings are environment variables. With Docker Compose, add them to t | `RATE_LIMIT` | `120` | Pings per minute per client (0 disables). Over-limit requests get `429`, keyed on the socket IP | | `RETENTION_DAYS` | `180` | Auto-delete pings older than this (GDPR Art. 5(1)(e) storage limitation). `0` disables automatic deletion | | `STATS_TOKEN` | *(empty = public)* | When set, `/v1/stats`, `/v1/overview`, and `/v1/export` require `?token=` or the `X-Stats-Token` header (header preferred - query strings end up in access logs). Also gates `DELETE /v1/packages/{package}` | -| `INGEST_TOKEN` | *(empty = open)* | When set, `/v1/telemetry/ping` requires `X-Statless-Token: ` or `Authorization: Bearer `. The SDK sends it from `STATLESS_TELEMETRY_TOKEN` | +| `INGEST_TOKEN` | *(empty = open)* | When set, `/v1/telemetry/ping` requires `X-Statless-Token: ` or `Authorization: Bearer `. The SDK sends it from `STATLESS_TELEMETRY_TOKEN` or `configure({ token })` | | `TELEMETRY_ENABLED` | `true` | When `false`, the collector accepts and silently drops pings (`204`) | | `CONTROLLER_NAME` | `statless-telemetry operator` | Legal entity or maintainer name displayed in `/privacy` notice | | `CONTROLLER_CONTACT` | *(empty)* | Contact email or URL for privacy inquiries in `/privacy` notice | @@ -408,7 +408,7 @@ Collector settings are environment variables. With Docker Compose, add them to t | Var | Default | Purpose | |---|---|---| | `STATLESS_TELEMETRY_URL` | *(empty = dormant)* | Ingest endpoint URL (e.g. your own `https://telemetry.example.com/v1/telemetry/ping`) | -| `STATLESS_TELEMETRY_TOKEN` | *(empty)* | Sent as `X-Statless-Token` when the collector sets `INGEST_TOKEN` | +| `STATLESS_TELEMETRY_TOKEN` | *(empty)* | Sent as `X-Statless-Token` when the collector sets `INGEST_TOKEN`. Overridden by `configure({ token })` | | `DO_NOT_TRACK` | *(empty)* | `1` disables the SDK entirely | | `STATLESS_OPTOUT` | *(empty)* | `1` disables the SDK entirely | diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d398e9d..2c87aa8 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -10,8 +10,9 @@ npm install @statless/telemetry ```ts import { configure, track } from "@statless/telemetry"; -// Configure your collector endpoint (or set STATLESS_TELEMETRY_URL) -configure({ endpoint: "https://telemetry.example.com/v1/telemetry/ping" }); +// Configure your collector endpoint and token (or set +// STATLESS_TELEMETRY_URL / STATLESS_TELEMETRY_TOKEN) +configure({ endpoint: "https://telemetry.example.com/v1/telemetry/ping", token: "shared-secret" }); const started = Date.now(); // ... run the command ... @@ -25,8 +26,9 @@ void track({ By default, telemetry remains dormant until an endpoint is explicitly configured with `configure({ endpoint })`, `track({ endpoint })`, or the `STATLESS_TELEMETRY_URL` -environment variable. Private collectors that set `INGEST_TOKEN` are supported with -`STATLESS_TELEMETRY_TOKEN`. +environment variable. A private collector that sets `INGEST_TOKEN` is supported with +`configure({ token })` or `STATLESS_TELEMETRY_TOKEN`; a configured token wins, and the +environment variable is read at call time so it remains the fallback. ## Privacy & Legal Compliance diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 52d34cc..358dce8 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@statless/telemetry", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@statless/telemetry", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "devDependencies": { "@types/node": "^26.0.0", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f51ff41..e789aa0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@statless/telemetry", - "version": "0.1.1", + "version": "0.1.2", "description": "Zero-dependency, cookie-free usage telemetry for developer CLIs and npm packages. Opt-out aware, pseudonymous rotating hashes, works standalone or self-hosted.", "license": "MIT", "author": "Dimitrios Xynos", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f217cfc..e0245c6 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,7 +4,7 @@ * * ```ts * import { configure, track } from "@statless/telemetry"; - * configure({ endpoint: "https://telemetry.example.com/v1/telemetry/ping" }); + * configure({ endpoint: "https://telemetry.example.com/v1/telemetry/ping", token: "shared-secret" }); * const started = Date.now(); * // ... run the command ... * void track({ package: "mytool", version: "1.2.3", command: "build", durationMs: Date.now() - started }); @@ -52,19 +52,27 @@ export interface TrackOptions { export interface TelemetryConfig { /** Collector endpoint. Defaults to `STATLESS_TELEMETRY_URL` env var. */ endpoint?: string; + /** + * Sent as `X-Statless-Token` when the collector requires one. Falls back to the + * `STATLESS_TELEMETRY_TOKEN` env var, read at call time. + */ + token?: string; /** Set `false` to disable telemetry for the whole process. */ enabled?: boolean; } /** Public hosted collector endpoint reference. */ -export const DEFAULT_HOSTED_ENDPOINT = "https://telemetry.statless.dev/v1/telemetry/ping"; +export const DEFAULT_HOSTED_ENDPOINT = "https://in.statless.dev/v1/telemetry/ping"; let endpoint = endpointOverride(); +let configuredToken: string | undefined; let enabled = true; -/** Adjust the endpoint or turn telemetry off for the whole process. */ +/** Adjust the endpoint, token, or turn telemetry off for the whole process. */ export function configure(config: TelemetryConfig): void { if (typeof config.endpoint === "string") endpoint = config.endpoint; + // An empty string clears the override so the env var (read at call time) applies. + if (typeof config.token === "string") configuredToken = config.token || undefined; if (typeof config.enabled === "boolean") enabled = config.enabled; } @@ -100,5 +108,5 @@ export function track(options: TrackOptions): Promise { payload.duration_ms = Math.max(0, Math.round(options.durationMs)); } - return send(targetEndpoint, payload, ingestToken()); + return send(targetEndpoint, payload, configuredToken ?? ingestToken()); } diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index bb58289..dfc2f20 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -28,7 +28,7 @@ beforeEach(() => { delete process.env.STATLESS_TELEMETRY_TOKEN; delete process.env.CI; delete process.env.GITHUB_ACTIONS; - configure({ enabled: true, endpoint: TEST_ENDPOINT }); + configure({ enabled: true, endpoint: TEST_ENDPOINT, token: "" }); }); afterEach(() => { @@ -73,6 +73,25 @@ describe("track", () => { ); }); + it("sends the token set via configure", async () => { + configure({ token: "from-config" }); + const { calls } = mockFetch(); + await track({ package: "my-cli", version: "1.0.0" }); + expect((calls[0]!.init.headers as Record)["x-statless-token"]).toBe( + "from-config", + ); + }); + + it("prefers the configured token over the environment", async () => { + process.env.STATLESS_TELEMETRY_TOKEN = "from-env"; + configure({ token: "from-config" }); + const { calls } = mockFetch(); + await track({ package: "my-cli", version: "1.0.0" }); + expect((calls[0]!.init.headers as Record)["x-statless-token"]).toBe( + "from-config", + ); + }); + it("omits optional fields when not provided", async () => { const { calls } = mockFetch(); await track({ package: "my-cli", version: "1.0.0" }); @@ -153,6 +172,6 @@ describe("track", () => { expect(isOptedOut()).toBe(false); process.env.STATLESS_OPTOUT = "1"; expect(isOptedOut()).toBe(true); - expect(DEFAULT_HOSTED_ENDPOINT).toBe("https://telemetry.statless.dev/v1/telemetry/ping"); + expect(DEFAULT_HOSTED_ENDPOINT).toBe("https://in.statless.dev/v1/telemetry/ping"); }); });