diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ddf780b8..0f9a66fa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -660,6 +660,15 @@ jobs: playwright-${{ runner.os }}- - run: npx playwright install --with-deps chromium - run: npx nx e2e website --skip-nx-cache + # The public-copy gate reads served output, and `next dev` serves a + # different bundle than production. Run it a second time against a real + # production build so a claim that only appears in the built output — in a + # generated bundle or a response body — cannot reach the site unnoticed. + - name: Public copy boundary (production build) + env: + GROWTH_FORM_POLICY: growth_v1 + WEBSITE_E2E_MODE: production + run: npx nx e2e website --skip-nx-cache --grep "public copy boundary|canonical policy surface" # The production-smoke spec is testIgnore'd outside PRODUCTION_SMOKE mode, # so a module-load error in it is invisible until the post-merge Production # smoke job runs against main — too late to gate a PR. Collect it here: diff --git a/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx index 193863efb..cf25cdef0 100644 --- a/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx +++ b/apps/website/content/blog/2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui.mdx @@ -347,7 +347,7 @@ The parts you will want on day thirty: - **Generative UI.** When the agent wants to render a richer surface than a tool-call card, `@threadplane/render` lets the backend stream a UI spec and the frontend resolves it against a registry of your approved Angular components. No arbitrary code, no `eval`, no design-system bypass. The agent picks from a menu you control. -- **Observability.** `@threadplane/telemetry` ships a PostHog-shaped sink that is *off by default*. Turn it on per-environment, point it at your own analytics, never ship app content to a vendor you did not pick. +- **Observability.** `@threadplane/telemetry` ships a PostHog-shaped sink your application supplies and configures per environment. Point it at your own analytics rather than at a vendor you did not pick. - **Testing.** Because the contract is signals all the way down, the testing story is "write a signal, the chat re-renders." `@threadplane/ag-ui` ships a `FakeAgent` you can hand-feed events to in a unit test. No SSE harness, no fixture loader, no test-only DI dance. Each of those is its own post. diff --git a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx index edd986f53..154ec1ca7 100644 --- a/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx +++ b/apps/website/content/blog/2026-08-09-agentic-ui-in-angular-production-patterns.mdx @@ -188,7 +188,7 @@ The [AG-UI testing guide](/docs/ag-ui/guides/testing) lays out those layers. Observe the transitions users feel: run duration, tool failures, interrupt wait time, retries, and thread restore failures. Keep event properties operational and out of prompt, completion, tool-input, and tool-output content unless your own policy explicitly requires otherwise. -Threadplane's [browser telemetry is opt-in](/docs/telemetry/getting-started/introduction), and an app-owned sink keeps that boundary under your control. +Threadplane's browser instrumentation runs through an app-owned sink, which keeps that boundary under your control. ## What about backend portability? diff --git a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx index 7adc34cd7..c8efdb8f1 100644 --- a/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx +++ b/apps/website/content/blog/2026-08-31-what-changes-when-the-runtime-changes.mdx @@ -246,7 +246,7 @@ Every time. - Checkpoint history, which is what makes `` possible at all. - Branch trees for time travel, and queued runs via a multitask strategy. - Rejoining an in-flight stream by run id, plus a configurable retry budget. -- Eight lifecycle signals that reset on thread switch. That is the difference between "we have telemetry" and "we can answer why that run was slow." +- Eight lifecycle signals that reset on thread switch. That is the difference between "we have metrics" and "we can answer why that run was slow." diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 6f3fe3877..4d00cc02b 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -390,7 +390,7 @@ { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", - "description": "Optional app-owned telemetry sink. No telemetry is emitted unless this is provided.", + "description": "Optional app-owned sink. Supply one to receive runtime lifecycle events.", "optional": true }, { @@ -540,7 +540,7 @@ { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", - "description": "Optional app-owned telemetry sink. No telemetry is emitted unless this is provided.", + "description": "Optional app-owned sink. Supply one to receive runtime lifecycle events.", "optional": true } ], diff --git a/apps/website/content/docs/ag-ui/api/inject-agent.mdx b/apps/website/content/docs/ag-ui/api/inject-agent.mdx index 368960b5f..791cf8bb4 100644 --- a/apps/website/content/docs/ag-ui/api/inject-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/inject-agent.mdx @@ -129,7 +129,7 @@ The method throws when the selected index is not an assistant message, when no p icon="settings" href="/docs/ag-ui/api/provide-agent" > - Configure the endpoint URL, headers, and telemetry for the agent provider. + Configure the endpoint URL, headers, and lifecycle sink for the agent provider. ` | Optional custom HTTP headers included on every request. | -| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned sink. Supply one to receive runtime lifecycle events. | ## Static vs factory config diff --git a/apps/website/content/docs/ag-ui/api/to-agent.mdx b/apps/website/content/docs/ag-ui/api/to-agent.mdx index bf66004f9..ae7d40ae8 100644 --- a/apps/website/content/docs/ag-ui/api/to-agent.mdx +++ b/apps/website/content/docs/ag-ui/api/to-agent.mdx @@ -20,7 +20,7 @@ const agent = toAgent(source, { telemetry: myTelemetrySink }); | Option | Type | Description | |--------|------|-------------| -| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned sink. Supply one to receive runtime lifecycle events. | ## AgUiAgent diff --git a/apps/website/content/docs/ag-ui/concepts/architecture.mdx b/apps/website/content/docs/ag-ui/concepts/architecture.mdx index 0a5acdb1f..dbc0ac765 100644 --- a/apps/website/content/docs/ag-ui/concepts/architecture.mdx +++ b/apps/website/content/docs/ag-ui/concepts/architecture.mdx @@ -111,7 +111,7 @@ provideAgent({ }); ``` -The config maps to the AG-UI `HttpAgent` options exposed by this package, plus an optional telemetry sink: +The config maps to the AG-UI `HttpAgent` options exposed by this package, plus an optional lifecycle event sink: | Option | Type | Description | |---|---|---| @@ -119,7 +119,7 @@ The config maps to the AG-UI `HttpAgent` options exposed by this package, plus a | `agentId` | `string` | Optional. Identifies a specific agent on the backend. | | `threadId` | `string` | Optional. Resume an existing conversation thread. | | `headers` | `Record` | Optional. Custom request headers (auth, tracing). | -| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned telemetry sink — opt-in, emits nothing unless supplied. See [`@threadplane/telemetry`](/docs/telemetry/getting-started/introduction). | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned sink for runtime lifecycle events. | ### Factory config for route params and DI diff --git a/apps/website/content/docs/ag-ui/getting-started/installation.mdx b/apps/website/content/docs/ag-ui/getting-started/installation.mdx index 0e719785a..2eec1cd03 100644 --- a/apps/website/content/docs/ag-ui/getting-started/installation.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/installation.mdx @@ -55,7 +55,7 @@ export const appConfig: ApplicationConfig = { | `agentId` | `string` | Optional. Identifies a specific agent on the backend. | | `threadId` | `string` | Optional. Resume an existing conversation thread. | | `headers` | `Record` | Optional. Custom request headers (auth, tracing). | -| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned telemetry sink — opt-in, emits nothing unless supplied. See [`@threadplane/telemetry`](/docs/telemetry/getting-started/introduction). | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional. App-owned sink for runtime lifecycle events. | ## Use in a component diff --git a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx index 4f7448e62..703cd9a7a 100644 --- a/apps/website/content/docs/ag-ui/reference/event-mapping.mdx +++ b/apps/website/content/docs/ag-ui/reference/event-mapping.mdx @@ -157,7 +157,7 @@ For every other custom event name, it emits: { type: 'custom', name, data: value } ``` -Use `state` for durable UI state. Use `events$` for transient events, telemetry hooks, or UI side effects that should not be stored as conversation state. +Use `state` for durable UI state. Use `events$` for transient events, observability hooks, or UI side effects that should not be stored as conversation state. Every non-`on_interrupt` `CUSTOM` event is fanned out to **two** surfaces, not one. Alongside the `events$` emission above, the reducer also appends `{ name, data }` to the AG-UI-specific `customEvents()` signal documented on [`injectAgent()`](/docs/ag-ui/api/inject-agent#ag-ui-specific-surface) and [`toAgent()`](/docs/ag-ui/api/to-agent). Reach for the signal when you want an accumulated per-run snapshot for reactive rendering (for example, `a2ui-partial` generative UI); reach for `events$` when you want a transient stream for side-effects or telemetry. diff --git a/apps/website/content/docs/chat/guides/error-handling.mdx b/apps/website/content/docs/chat/guides/error-handling.mdx index ccf83a39c..ed6c14fe9 100644 --- a/apps/website/content/docs/chat/guides/error-handling.mdx +++ b/apps/website/content/docs/chat/guides/error-handling.mdx @@ -17,7 +17,7 @@ class AgentError extends Error { readonly kind: AgentErrorKind; // 'connection' | 'auth' | 'server' | 'interrupted' | 'aborted' readonly retryable: boolean; // could retrying the same request plausibly succeed? readonly status?: number; // HTTP status, when the failure came from a response - readonly cause: unknown; // the original raw error, preserved for debugging/telemetry + readonly cause: unknown; // the original raw error, preserved for debugging and diagnostics } ``` diff --git a/apps/website/content/docs/chat/guides/lifecycle.mdx b/apps/website/content/docs/chat/guides/lifecycle.mdx index 74c8f2d54..4fd0569c3 100644 --- a/apps/website/content/docs/chat/guides/lifecycle.mdx +++ b/apps/website/content/docs/chat/guides/lifecycle.mdx @@ -49,7 +49,3 @@ export class MyComponent { | `firstMessageSent` | **no (sticky for life of ``)** | | `messageCount` | yes (to 0) | | `inputSubmittedAt` | yes (to null) | - -## Privacy - -These signals contain no message content, no user input, no PII. They are timestamps and counts only. The trust contract at [libs/telemetry/README.md](https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md) applies: **no app telemetry by default.** Subscribing to `CHAT_LIFECYCLE` in your code does not fire any telemetry; what you do with the signal values is your choice. diff --git a/apps/website/content/docs/chat/guides/thread-routing.mdx b/apps/website/content/docs/chat/guides/thread-routing.mdx index 19a5b61e0..70d843b77 100644 --- a/apps/website/content/docs/chat/guides/thread-routing.mdx +++ b/apps/website/content/docs/chat/guides/thread-routing.mdx @@ -181,6 +181,6 @@ The AG-UI protocol is event-stream-only — it does not define a server-side thr icon="activity" href="/docs/chat/guides/lifecycle" > - Per-instance signals for debugging and telemetry. + Per-instance signals for debugging and observability. diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index 23a2b53f0..cabe07265 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -919,7 +919,7 @@ { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", - "description": "Optional app-owned telemetry sink. No telemetry is emitted unless this is provided.", + "description": "Optional app-owned sink. Supply one to receive runtime lifecycle events.", "optional": true }, { @@ -1061,7 +1061,7 @@ { "name": "telemetry", "type": "false | AgentRuntimeTelemetrySink", - "description": "Optional app-owned telemetry sink. No telemetry is emitted unless this is provided.", + "description": "Optional app-owned sink. Supply one to receive runtime lifecycle events.", "optional": true }, { diff --git a/apps/website/content/docs/langgraph/api/provide-agent.mdx b/apps/website/content/docs/langgraph/api/provide-agent.mdx index 697581cfe..80d040a48 100644 --- a/apps/website/content/docs/langgraph/api/provide-agent.mdx +++ b/apps/website/content/docs/langgraph/api/provide-agent.mdx @@ -38,7 +38,7 @@ bootstrapApplication(AppComponent, { | `toMessage` | `(msg: unknown) => BaseMessage` | Custom message deserializer for non-standard message formats. | | `transport` | `AgentTransport` | Optional transport instance. Defaults to `FetchStreamTransport` when omitted. | | `clientOptions` | `LangGraphClientOptions` | LangGraph SDK client tuning (e.g. `maxRetries`). See [Client tuning](#client-tuning-retry-budget) below. | -| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned sink. Supply one to receive runtime lifecycle events. | | `subagentToolNames` | `string[]` | Tool names that indicate a subagent invocation. | | `transcriptNodeNames` | `string[]` | LangGraph node names whose `messages-tuple` chunks should stream into the main chat transcript. Omit to accept all top-level chunks. | diff --git a/apps/website/content/docs/langgraph/getting-started/introduction.mdx b/apps/website/content/docs/langgraph/getting-started/introduction.mdx index 19a5b7e61..ae9fb1ed7 100644 --- a/apps/website/content/docs/langgraph/getting-started/introduction.mdx +++ b/apps/website/content/docs/langgraph/getting-started/introduction.mdx @@ -251,7 +251,7 @@ Everything `injectAgent()` gives you out of the box — click any to learn more: -That same agent also exposes `injectAgent().lifecycle` — eight read-only signals that capture key transitions (first chunk, first interrupt, tool start/complete, errors) for debugging and telemetry. See [Lifecycle Signals](/docs/langgraph/guides/lifecycle). +That same agent also exposes `injectAgent().lifecycle` — eight read-only signals that capture key transitions (first chunk, first interrupt, tool start/complete, errors) for debugging and observability. See [Lifecycle Signals](/docs/langgraph/guides/lifecycle). ## Deploy to Production @@ -333,7 +333,7 @@ Your Angular app is a stateless client. All agent state — threads, checkpoints Deterministic testing with MockAgentTransport - Read per-agent lifecycle signals for debugging and telemetry + Read per-agent lifecycle signals for debugging and observability Deep dive into how Signals power agent diff --git a/apps/website/content/docs/langgraph/guides/lifecycle.mdx b/apps/website/content/docs/langgraph/guides/lifecycle.mdx index 76587b297..6055729d6 100644 --- a/apps/website/content/docs/langgraph/guides/lifecycle.mdx +++ b/apps/website/content/docs/langgraph/guides/lifecycle.mdx @@ -91,7 +91,3 @@ The exported `AGENT_LIFECYCLE` token is a low-level token for custom integration ## Reset semantics All eight signals reset on `switchThread()`. This keeps lifecycle observations scoped to the current thread. - -## Privacy - -These signals contain no message content, no model output, no PII. They are timestamps, counts, and short classification strings only. The trust contract at [libs/telemetry/README.md](https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md) applies: **no app telemetry by default.** Reading lifecycle signals or providing `AgentLifecycleRegistry` does not fire any telemetry; what you do with the signal values is your choice. diff --git a/apps/website/content/docs/render/guides/lifecycle.mdx b/apps/website/content/docs/render/guides/lifecycle.mdx index c5d0d3354..628b66345 100644 --- a/apps/website/content/docs/render/guides/lifecycle.mdx +++ b/apps/website/content/docs/render/guides/lifecycle.mdx @@ -59,7 +59,3 @@ export class MyComponent { ## Reset semantics `firstMountAt` is sticky for the life of the render context — once set, it does not reset. The remaining four signals update on every relevant event. - -## Privacy - -These signals contain no spec content, no state values, no handler parameters. They are timestamps, counts, and short discriminants (`spec` / `element`, action names from the registered handler bindings) only. The trust contract at [libs/telemetry/README.md](https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md) applies: **no app telemetry by default.** Subscribing to `RENDER_LIFECYCLE` in your code does not fire any telemetry; what you do with the signal values is your choice. diff --git a/apps/website/content/docs/telemetry/api/api-docs.json b/apps/website/content/docs/telemetry/api/api-docs.json deleted file mode 100644 index d9df02b95..000000000 --- a/apps/website/content/docs/telemetry/api/api-docs.json +++ /dev/null @@ -1,824 +0,0 @@ -[ - { - "name": "ThreadplaneTelemetryService", - "kind": "class", - "description": "Browser-side telemetry service.\n\nThe service no-ops unless `provideThreadplaneTelemetry({ enabled: true })`\nconfigured it. It enriches sent events with `sample_weight`, then delivers\nthem through `sink`, `endpoint`, or legacy PostHog configuration.", - "params": [], - "examples": [], - "properties": [], - "methods": [ - { - "name": "capture", - "signature": "capture(event: ThreadplaneTelemetryEvent, properties: Record): Promise", - "description": "Capture an arbitrary enabled browser telemetry event.", - "params": [ - { - "name": "event", - "type": "ThreadplaneTelemetryEvent", - "description": "", - "optional": false - }, - { - "name": "properties", - "type": "Record", - "description": "", - "optional": true - } - ] - }, - { - "name": "captureRuntimeInstanceCreated", - "signature": "captureRuntimeInstanceCreated(input: ThreadplaneBrowserRuntimeTelemetry): Promise", - "description": "Capture a runtime construction event.", - "params": [ - { - "name": "input", - "type": "ThreadplaneBrowserRuntimeTelemetry", - "description": "", - "optional": false - } - ] - }, - { - "name": "captureRuntimeRequestCreated", - "signature": "captureRuntimeRequestCreated(input: ThreadplaneBrowserRuntimeTelemetry & { requestType: string }): Promise", - "description": "Capture a runtime request creation event.", - "params": [ - { - "name": "input", - "type": "ThreadplaneBrowserRuntimeTelemetry & { requestType: string }", - "description": "", - "optional": false - } - ] - }, - { - "name": "captureStreamEnded", - "signature": "captureStreamEnded(input: ThreadplaneBrowserStreamTelemetry): Promise", - "description": "Capture a stream-end event.", - "params": [ - { - "name": "input", - "type": "ThreadplaneBrowserStreamTelemetry", - "description": "", - "optional": false - } - ] - }, - { - "name": "captureStreamErrored", - "signature": "captureStreamErrored(input: ThreadplaneBrowserStreamErrorTelemetry): Promise", - "description": "Capture a stream-error event, sending only the derived error class.", - "params": [ - { - "name": "input", - "type": "ThreadplaneBrowserStreamErrorTelemetry", - "description": "", - "optional": false - } - ] - }, - { - "name": "captureStreamStarted", - "signature": "captureStreamStarted(input: ThreadplaneBrowserStreamTelemetry): Promise", - "description": "Capture a stream-start event.", - "params": [ - { - "name": "input", - "type": "ThreadplaneBrowserStreamTelemetry", - "description": "", - "optional": false - } - ] - } - ] - }, - { - "name": "ThreadplaneBrowserRuntimeTelemetry", - "kind": "interface", - "description": "Runtime lifecycle properties captured from browser-side agent adapters.", - "properties": [ - { - "name": "model", - "type": "string", - "description": "Optional model name.", - "optional": true - }, - { - "name": "provider", - "type": "string", - "description": "Optional model provider name.", - "optional": true - }, - { - "name": "surface", - "type": "string", - "description": "Optional product or app surface tag.", - "optional": true - }, - { - "name": "transport", - "type": "string", - "description": "Runtime transport, such as `langgraph` or `ag-ui`.", - "optional": false - } - ], - "examples": [] - }, - { - "name": "ThreadplaneBrowserStreamErrorTelemetry", - "kind": "interface", - "description": "Stream error telemetry. The raw error is reduced to an error class.", - "properties": [ - { - "name": "durationMs", - "type": "number", - "description": "Stream duration in milliseconds, when known.", - "optional": true - }, - { - "name": "error", - "type": "unknown", - "description": "Raw error object or value; capture sends only `errorClass`.", - "optional": true - }, - { - "name": "model", - "type": "string", - "description": "Optional model name.", - "optional": true - }, - { - "name": "provider", - "type": "string", - "description": "Optional model provider name.", - "optional": true - }, - { - "name": "surface", - "type": "string", - "description": "Optional product or app surface tag.", - "optional": true - }, - { - "name": "transport", - "type": "string", - "description": "Runtime transport, such as `langgraph` or `ag-ui`.", - "optional": false - } - ], - "examples": [] - }, - { - "name": "ThreadplaneBrowserStreamTelemetry", - "kind": "interface", - "description": "Stream telemetry properties captured from browser-side agent adapters.", - "properties": [ - { - "name": "durationMs", - "type": "number", - "description": "Stream duration in milliseconds, when known.", - "optional": true - }, - { - "name": "model", - "type": "string", - "description": "Optional model name.", - "optional": true - }, - { - "name": "provider", - "type": "string", - "description": "Optional model provider name.", - "optional": true - }, - { - "name": "surface", - "type": "string", - "description": "Optional product or app surface tag.", - "optional": true - }, - { - "name": "transport", - "type": "string", - "description": "Runtime transport, such as `langgraph` or `ag-ui`.", - "optional": false - } - ], - "examples": [] - }, - { - "name": "ThreadplaneTelemetryConfig", - "kind": "interface", - "description": "", - "properties": [ - { - "name": "enabled", - "type": "boolean", - "description": "", - "optional": false - }, - { - "name": "endpoint", - "type": "string", - "description": "Preferred app-owned ingest URL. The browser service POSTs neutral event\npayloads here; the endpoint decides where they ultimately go.", - "optional": true - }, - { - "name": "posthogHost", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "posthogKey", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "sampleRate", - "type": "number", - "description": "", - "optional": true - }, - { - "name": "sink", - "type": "ThreadplaneTelemetrySink", - "description": "Preferred app-owned delivery hook. Use this when the consuming app wants\nto forward events through its own analytics boundary.", - "optional": true - } - ], - "examples": [] - }, - { - "name": "ThreadplaneTelemetryEventPayload", - "kind": "interface", - "description": "", - "properties": [ - { - "name": "event", - "type": "ThreadplaneTelemetryEvent", - "description": "", - "optional": false - }, - { - "name": "properties", - "type": "Record", - "description": "", - "optional": true - } - ], - "examples": [] - }, - { - "name": "CaptureConfig", - "kind": "type", - "description": "", - "signature": "unknown", - "examples": [] - }, - { - "name": "ThreadplaneBrowserEvent", - "kind": "type", - "description": "", - "signature": "ThreadplaneTelemetryEvent", - "examples": [] - }, - { - "name": "ThreadplaneTelemetryEvent", - "kind": "type", - "description": "", - "signature": "\"tplane:browser_provided\" | \"tplane:browser_chat_init\" | \"tplane:runtime_instance_created\" | \"tplane:runtime_request_created\" | \"tplane:stream_started\" | \"tplane:stream_ended\" | \"tplane:stream_errored\"", - "examples": [] - }, - { - "name": "ThreadplaneTelemetrySink", - "kind": "type", - "description": "", - "signature": "(payload: ThreadplaneTelemetryEventPayload) => void | Promise", - "examples": [] - }, - { - "name": "THREADPLANE_TELEMETRY_CONFIG", - "kind": "const", - "description": "", - "signature": "InjectionToken", - "examples": [] - }, - { - "name": "isLocalAnalyticsHost", - "kind": "function", - "description": "", - "signature": "isLocalAnalyticsHost(host: unknown): boolean", - "params": [ - { - "name": "host", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "boolean", - "description": "" - }, - "examples": [] - }, - { - "name": "provideThreadplaneTelemetry", - "kind": "function", - "description": "Provide browser telemetry configuration for an Angular app.\n\nBrowser telemetry remains off unless this provider is installed with\n`enabled: true`. Delivery goes through `sink`, `endpoint`, or the legacy\nPostHog options configured on `ThreadplaneTelemetryConfig`.", - "signature": "provideThreadplaneTelemetry(config: ThreadplaneTelemetryConfig): EnvironmentProviders", - "params": [ - { - "name": "config", - "type": "ThreadplaneTelemetryConfig", - "description": "", - "optional": false - } - ], - "returns": { - "type": "EnvironmentProviders", - "description": "" - }, - "examples": [] - }, - { - "name": "shouldCaptureAnalytics", - "kind": "function", - "description": "", - "signature": "shouldCaptureAnalytics(__namedParameters: CaptureConfig): boolean", - "params": [ - { - "name": "__namedParameters", - "type": "CaptureConfig", - "description": "", - "optional": false - } - ], - "returns": { - "type": "boolean", - "description": "" - }, - "examples": [] - }, - { - "name": "ThreadplaneBrowserEvent", - "kind": "type", - "description": "", - "signature": "\"tplane:browser_provided\" | \"tplane:browser_chat_init\"", - "examples": [] - }, - { - "name": "ThreadplaneEvent", - "kind": "type", - "description": "", - "signature": "ThreadplaneNodeEvent | ThreadplaneBrowserEvent", - "examples": [] - }, - { - "name": "ThreadplaneNodeEvent", - "kind": "type", - "description": "", - "signature": "\"tplane:runtime_instance_created\" | \"tplane:runtime_request_created\" | \"tplane:stream_started\" | \"tplane:stream_ended\" | \"tplane:stream_errored\"", - "examples": [] - }, - { - "name": "getAnonId", - "kind": "function", - "description": "", - "signature": "getAnonId(): string", - "params": [], - "returns": { - "type": "string", - "description": "" - }, - "examples": [] - }, - { - "name": "getDisableReason", - "kind": "function", - "description": "", - "signature": "getDisableReason(env: ProcessEnv<>): DisableReason", - "params": [ - { - "name": "env", - "type": "ProcessEnv<>", - "description": "", - "optional": true - } - ], - "returns": { - "type": "DisableReason", - "description": "" - }, - "examples": [] - }, - { - "name": "isTelemetryDisabled", - "kind": "function", - "description": "", - "signature": "isTelemetryDisabled(env: ProcessEnv<>): boolean", - "params": [ - { - "name": "env", - "type": "ProcessEnv<>", - "description": "", - "optional": true - } - ], - "returns": { - "type": "boolean", - "description": "" - }, - "examples": [] - }, - { - "name": "sha256", - "kind": "function", - "description": "", - "signature": "sha256(input: string): Promise", - "params": [ - { - "name": "input", - "type": "string", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "shouldSample", - "kind": "function", - "description": "", - "signature": "shouldSample(rate: number, anonId: string): boolean", - "params": [ - { - "name": "rate", - "type": "number", - "description": "", - "optional": false - }, - { - "name": "anonId", - "type": "string", - "description": "", - "optional": false - } - ], - "returns": { - "type": "boolean", - "description": "" - }, - "examples": [] - }, - { - "name": "RuntimeInstanceTelemetry", - "kind": "interface", - "description": "", - "properties": [ - { - "name": "angularVersion", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "apiKey", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "model", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "provider", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "transport", - "type": "string", - "description": "", - "optional": false - } - ], - "examples": [] - }, - { - "name": "RuntimeRequestTelemetry", - "kind": "interface", - "description": "", - "properties": [ - { - "name": "model", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "provider", - "type": "string", - "description": "", - "optional": true - }, - { - "name": "requestType", - "type": "string", - "description": "", - "optional": false - }, - { - "name": "transport", - "type": "string", - "description": "", - "optional": false - } - ], - "examples": [] - }, - { - "name": "StreamTelemetry", - "kind": "interface", - "description": "", - "properties": [ - { - "name": "durationMs", - "type": "number", - "description": "", - "optional": true - }, - { - "name": "model", - "type": "string", - "description": "", - "optional": false - }, - { - "name": "provider", - "type": "string", - "description": "", - "optional": false - } - ], - "examples": [] - }, - { - "name": "CaptureResult", - "kind": "type", - "description": "", - "signature": "object | object", - "examples": [] - }, - { - "name": "captureEvent", - "kind": "function", - "description": "", - "signature": "captureEvent(event: ThreadplaneNodeEvent, properties: Record): Promise", - "params": [ - { - "name": "event", - "type": "ThreadplaneNodeEvent", - "description": "", - "optional": false - }, - { - "name": "properties", - "type": "Record", - "description": "", - "optional": true - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "captureRuntimeInstanceCreated", - "kind": "function", - "description": "", - "signature": "captureRuntimeInstanceCreated(input: RuntimeInstanceTelemetry): Promise", - "params": [ - { - "name": "input", - "type": "RuntimeInstanceTelemetry", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "captureRuntimeRequestCreated", - "kind": "function", - "description": "", - "signature": "captureRuntimeRequestCreated(input: RuntimeRequestTelemetry): Promise", - "params": [ - { - "name": "input", - "type": "RuntimeRequestTelemetry", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "captureStreamEnded", - "kind": "function", - "description": "", - "signature": "captureStreamEnded(input: StreamTelemetry): Promise", - "params": [ - { - "name": "input", - "type": "StreamTelemetry", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "captureStreamErrored", - "kind": "function", - "description": "", - "signature": "captureStreamErrored(input: StreamTelemetry & { error: unknown }): Promise", - "params": [ - { - "name": "input", - "type": "StreamTelemetry & { error: unknown }", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "captureStreamStarted", - "kind": "function", - "description": "", - "signature": "captureStreamStarted(input: StreamTelemetry): Promise", - "params": [ - { - "name": "input", - "type": "StreamTelemetry", - "description": "", - "optional": false - } - ], - "returns": { - "type": "Promise", - "description": "" - }, - "examples": [] - }, - { - "name": "disableTelemetry", - "kind": "function", - "description": "", - "signature": "disableTelemetry(): void", - "params": [], - "returns": { - "type": "void", - "description": "" - }, - "examples": [] - }, - { - "name": "PERSONAL_EMAIL_DOMAINS", - "kind": "const", - "description": "", - "signature": "ReadonlySet", - "examples": [] - }, - { - "name": "getEmailDomain", - "kind": "function", - "description": "", - "signature": "getEmailDomain(email: unknown): string | null", - "params": [ - { - "name": "email", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "string | null", - "description": "" - }, - "examples": [] - }, - { - "name": "getSourcePage", - "kind": "function", - "description": "", - "signature": "getSourcePage(value: unknown): string", - "params": [ - { - "name": "value", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "string", - "description": "" - }, - "examples": [] - }, - { - "name": "isPersonalEmailDomain", - "kind": "function", - "description": "", - "signature": "isPersonalEmailDomain(domain: string | null | undefined): boolean", - "params": [ - { - "name": "domain", - "type": "string | null | undefined", - "description": "", - "optional": false - } - ], - "returns": { - "type": "boolean", - "description": "" - }, - "examples": [] - }, - { - "name": "normalizePostHogHost", - "kind": "function", - "description": "", - "signature": "normalizePostHogHost(host: unknown): string", - "params": [ - { - "name": "host", - "type": "unknown", - "description": "", - "optional": false - } - ], - "returns": { - "type": "string", - "description": "" - }, - "examples": [] - }, - { - "name": "toSafeAnalyticsString", - "kind": "function", - "description": "", - "signature": "toSafeAnalyticsString(value: unknown, maxLength: number): string | undefined", - "params": [ - { - "name": "value", - "type": "unknown", - "description": "", - "optional": false - }, - { - "name": "maxLength", - "type": "number", - "description": "", - "optional": true - } - ], - "returns": { - "type": "string | undefined", - "description": "" - }, - "examples": [] - } -] \ No newline at end of file diff --git a/apps/website/content/docs/telemetry/getting-started/installation.mdx b/apps/website/content/docs/telemetry/getting-started/installation.mdx deleted file mode 100644 index 3dfc7c5be..000000000 --- a/apps/website/content/docs/telemetry/getting-started/installation.mdx +++ /dev/null @@ -1,66 +0,0 @@ -# Installation - -Install the telemetry package: - -```bash -npm install @threadplane/telemetry -``` - -The package publishes separate entry points for browser, Node, and shared utilities: - -```ts -import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; -import { captureEvent, disableTelemetry } from '@threadplane/telemetry/node'; -import { isTelemetryDisabled } from '@threadplane/telemetry'; -``` - -## Optional peers - -`@angular/core` and `posthog-js` are optional peer dependencies. - -Install `@angular/core` when you use the browser Angular provider and service: - -```bash -npm install @threadplane/telemetry @angular/core -``` - -Install `posthog-js` only when you use direct browser PostHog delivery through `posthogKey`. New integrations should prefer `sink` or `endpoint`, because those keep delivery inside your application's analytics boundary. - -```bash -npm install @threadplane/telemetry posthog-js -``` - -## Browser entry point - -Browser telemetry is opt-in. Calling `provideThreadplaneTelemetry({ enabled: true, ... })` is what activates capture. - -```ts -import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; - -provideThreadplaneTelemetry({ - enabled: true, - endpoint: '/api/telemetry', -}); -``` - -## Node entry point - -Node helpers live under `@threadplane/telemetry/node`. - -```ts -import { captureRuntimeInstanceCreated } from '@threadplane/telemetry/node'; - -await captureRuntimeInstanceCreated({ - transport: 'langgraph', - provider: 'openai', - model: 'gpt-4.1', -}); -``` - -Node telemetry respects the environment opt-out variables documented in [Privacy and Opt-Out](/docs/telemetry/guides/privacy-and-opt-out). - -## Next steps - -- [Browser Telemetry](/docs/telemetry/guides/browser) - configure an Angular provider and runtime sink. -- [Node Telemetry](/docs/telemetry/guides/node) - capture helpers for lifecycle and stream events. -- [Events](/docs/telemetry/reference/events) - event names and payload shapes. diff --git a/apps/website/content/docs/telemetry/getting-started/introduction.mdx b/apps/website/content/docs/telemetry/getting-started/introduction.mdx deleted file mode 100644 index 55c357c8c..000000000 --- a/apps/website/content/docs/telemetry/getting-started/introduction.mdx +++ /dev/null @@ -1,59 +0,0 @@ -# Introduction - -`@threadplane/telemetry` is the shared telemetry package for the framework. It has separate surfaces for browser applications, Node adapters, and shared utilities. - -The important boundary is simple: - -- browser telemetry is opt-in through Angular DI; -- Node telemetry runs only when application or adapter code explicitly calls a capture helper; -- shared utilities expose event and environment helpers. - -Don't treat the browser and Node entry points as interchangeable. They have different runtime assumptions and different privacy defaults. - -## How it fits - - - -## Entry points - -```ts -// Shared utilities -import { isTelemetryDisabled, getDisableReason } from '@threadplane/telemetry'; - -// Browser Angular provider and service -import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; - -// Node/server helpers -import { disableTelemetry, captureEvent } from '@threadplane/telemetry/node'; -``` - -## Browser posture - -The browser package does nothing unless the application explicitly provides telemetry config with `enabled: true`. - -When enabled, delivery is chosen in this order: - -1. `sink`; -2. `endpoint`; -3. `posthogKey`. - -For me, `sink` and `endpoint` are the paths to reach for. They keep delivery app-owned, so you decide where events go. `posthogKey` and `posthogHost` still exist for older integrations, but they're marked deprecated in source comments, so don't build anything new on them. - -## Node posture - -Installing the package is inert: it has no install lifecycle hook and makes no network request. Node helpers check disable environment variables before an explicit capture call. They use a per-process anonymous ID and deterministic sampling based on that ID. - -Node capture failures return a failure result or are swallowed by adapter helpers. Telemetry should not break startup or request handling. - -## What this package does not promise - -The source does not contain content capture for prompts, completions, tool inputs, or tool outputs. It also does not persist browser IDs to local storage or cookies. - -It does collect runtime metadata when the corresponding Node or browser capture APIs are called. Keep event properties short, operational, and free of application data. - -## Next steps - -- [Browser Telemetry](/docs/telemetry/guides/browser) - wire `provideThreadplaneTelemetry()` and bridge the agent runtime into a sink. -- [Node Telemetry](/docs/telemetry/guides/node) - explicit capture helpers for server adapters. -- [Privacy and opt-out](/docs/telemetry/guides/privacy-and-opt-out) - what's collected and how to turn it off. -- [Events](/docs/telemetry/reference/events) - the event names and property shapes emitted by each surface. diff --git a/apps/website/content/docs/telemetry/guides/browser.mdx b/apps/website/content/docs/telemetry/guides/browser.mdx deleted file mode 100644 index 4263ff815..000000000 --- a/apps/website/content/docs/telemetry/guides/browser.mdx +++ /dev/null @@ -1,229 +0,0 @@ -# Browser Telemetry - -Browser telemetry is opt-in. If your Angular app never calls `provideThreadplaneTelemetry()`, the service has no enabled config and `capture()` returns without sending anything. - -## Configure - -```ts -import type { ApplicationConfig } from '@angular/core'; -import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideThreadplaneTelemetry({ - enabled: true, - endpoint: '/api/telemetry', - sampleRate: 1, - }), - ], -}; -``` - -The endpoint receives: - -```json -{ - "event": "tplane:stream_started", - "distinctId": "browser:", - "properties": { - "transport": "langgraph", - "sample_weight": 1 - } -} -``` - -The browser distinct ID is generated per service instance. The source never writes it to storage. - -### Handle the endpoint - -`endpoint` only POSTs the payload above — your app owns the route that receives it. A minimal handler reads `{ event, distinctId, properties }` and forwards or stores it. Here's a Node-style handler (the same shape works in an Express route, an Angular SSR server route, or any framework API route): - -```ts -import type { IncomingMessage, ServerResponse } from 'node:http'; - -interface TelemetryRequest { - event: string; - distinctId: string; - properties?: Record; -} - -export async function handleTelemetry(req: IncomingMessage, res: ServerResponse): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - const { event, distinctId, properties } = JSON.parse(Buffer.concat(chunks).toString()) as TelemetryRequest; - - // Forward to your analytics backend, or store the row. Keep it off the - // request's critical path — the browser uses keepalive and ignores the response. - await fetch('https://example-analytics.invalid/track', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ event, distinctId, properties }), - }); - - res.statusCode = 204; - res.end(); -} -``` - -The browser POST sets `keepalive: true` and ignores the response body, so return a fast `204` and do any slow work asynchronously. - -## Prefer a sink for app-owned analytics - -Use `sink` when your app already has an analytics boundary. - -```ts -provideThreadplaneTelemetry({ - enabled: true, - sink: async ({ event, properties }) => { - await analytics.track(event, properties); - }, -}); -``` - -When `sink` is present, the service skips `endpoint` and PostHog entirely. - -## Wire the runtime to telemetry - -You rarely call the capture methods by hand. The agent runtime emits the stream and runtime-lifecycle events for you — you just hand it a sink. `provideAgent()` (from `@threadplane/langgraph`) takes a `telemetry?: AgentRuntimeTelemetrySink | false` option. No telemetry is emitted unless you pass a sink; pass `false` to disable it explicitly. - -The canonical pattern bridges that runtime sink into `ThreadplaneTelemetryService.capture()`, so runtime events flow through the same `sink`/`endpoint` config you set up above. This mirrors `createCanonicalDemoRuntimeTelemetrySink` in the chat example app — it strips conversational fields and stamps on a surface tag before forwarding: - -```ts -import type { - AgentRuntimeTelemetryEvent, - AgentRuntimeTelemetrySink, -} from '@threadplane/chat'; -import type { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; - -// Never forward conversational payloads through telemetry. -const BLOCKED_PROPERTY_KEYS = new Set(['messages', 'threadId', 'assistantId', 'apiUrl']); - -export function createRuntimeTelemetrySink( - telemetry: Pick, - surface: string, -): AgentRuntimeTelemetrySink { - return ({ event, properties }) => { - const safeProperties: Record = {}; - for (const [key, value] of Object.entries(properties ?? {})) { - if (!BLOCKED_PROPERTY_KEYS.has(key)) safeProperties[key] = value; - } - return telemetry.capture(event as AgentRuntimeTelemetryEvent, { - ...safeProperties, - surface, - }); - }; -} -``` - -Then pass the bridged sink to `provideAgent()`. Use the factory form (`provideAgent(() => ...)`) so `inject()` runs once inside the provider's injection context — calling `inject()` lazily inside the per-event sink callback throws `NG0203`, because the runtime fires those events outside any injection context: - -```ts -import { inject } from '@angular/core'; -import { provideAgent } from '@threadplane/langgraph'; -import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; -import { createRuntimeTelemetrySink } from './runtime-telemetry'; - -provideAgent(() => ({ - apiUrl: 'http://localhost:2024', - assistantId: 'chat', - telemetry: createRuntimeTelemetrySink(inject(ThreadplaneTelemetryService), 'my_app'), -})); -``` - -Now `tplane:stream_started`, `tplane:stream_ended`, and the other runtime events reach your sink or endpoint automatically — no per-event `capture()` call in your component. - -## Sampling - -`sampleRate` is normalized: - -- missing, invalid, or non-finite values become `1`; -- values less than or equal to `0` disable capture; -- values greater than or equal to `1` capture every event; -- values between `0` and `1` use `Math.random()`. - -Captured events include `sample_weight`. When the sample rate is `0.25`, the default weight is `4`. - -## Events captured by the service - -The service exposes convenience methods: - -```ts -telemetry.captureRuntimeInstanceCreated({ - transport: 'langgraph', - provider: 'openai', - model: 'gpt-4.1', -}); - -telemetry.captureRuntimeRequestCreated({ - transport: 'langgraph', - requestType: 'stream', - provider: 'openai', - model: 'gpt-4.1', -}); - -telemetry.captureStreamStarted({ transport: 'langgraph', provider: 'openai', model: 'gpt-4.1' }); -telemetry.captureStreamEnded({ transport: 'langgraph', provider: 'openai', model: 'gpt-4.1', durationMs: 1200 }); -telemetry.captureStreamErrored({ transport: 'langgraph', provider: 'openai', model: 'gpt-4.1', error }); -``` - -`captureStreamErrored()` sends `errorClass`, not the raw error object. - -## End-to-end example - -Here's the whole path firing in one place: a `sink` wired in `app.config.ts`, a component that injects `ThreadplaneTelemetryService` and calls a capture method on a click, and the sink logging the resulting `{ event, properties }`. - -```ts -// app.config.ts -import type { ApplicationConfig } from '@angular/core'; -import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideThreadplaneTelemetry({ - enabled: true, - // The sink receives every captured event. Here we just log it. - sink: ({ event, properties }) => { - console.log('telemetry', event, properties); - }, - }), - ], -}; -``` - -```ts -// telemetry-demo.component.ts -import { Component, inject } from '@angular/core'; -import { ThreadplaneTelemetryService } from '@threadplane/telemetry/browser'; - -@Component({ - selector: 'app-telemetry-demo', - standalone: true, - template: ``, -}) -export class TelemetryDemoComponent { - private readonly telemetry = inject(ThreadplaneTelemetryService); - - onStart(): void { - // Returns a Promise; capture failures are swallowed, so no need to await. - void this.telemetry.captureStreamStarted({ - transport: 'langgraph', - provider: 'openai', - model: 'gpt-4.1', - }); - } -} -``` - -Clicking the button logs: - -```text -telemetry tplane:stream_started { transport: 'langgraph', provider: 'openai', model: 'gpt-4.1', sample_weight: 1 } -``` - -`sample_weight` is added by the service from `sampleRate` (default `1`). In production you'd point `sink` at your analytics boundary instead of `console.log`, or use `endpoint` and the handler above. - -## Delivery failures - -Browser capture is wrapped in a `try/catch`. A sink error, fetch failure, or dynamic import failure is swallowed. - -That keeps telemetry out of your application's control flow. diff --git a/apps/website/content/docs/telemetry/guides/node.mdx b/apps/website/content/docs/telemetry/guides/node.mdx deleted file mode 100644 index c539894a2..000000000 --- a/apps/website/content/docs/telemetry/guides/node.mdx +++ /dev/null @@ -1,126 +0,0 @@ -# Node Telemetry - -Node telemetry lives under `@threadplane/telemetry/node`. Installing it is inert. Events are sent only when application or server-adapter code calls a capture helper. - -```ts -import { - captureRuntimeInstanceCreated, - captureRuntimeRequestCreated, - captureStreamStarted, - captureStreamEnded, - captureStreamErrored, - disableTelemetry, -} from '@threadplane/telemetry/node'; -``` - -## When to call these - -These helpers are for server-side runtime and adapter code, not application request handlers. `captureRuntimeInstanceCreated()` fires when a runtime is constructed; `captureStreamStarted()`/`captureStreamEnded()` wrap a model stream. In a typical deployment that's adapter or framework-integration code — your route handlers and business logic don't call them directly. - -If you want to opt the whole process out, call `disableTelemetry()` once at startup, before any capture helper runs. The flag is checked at capture time, but it has to be set first to take effect for the calls you care about. - -## Opt out programmatically - -Call `disableTelemetry()` before capture helpers run. - -```ts -import { disableTelemetry } from '@threadplane/telemetry/node'; - -disableTelemetry(); -``` - -This sets an in-process flag. It doesn't mutate environment variables. - -## Capture runtime lifecycle - -```ts -await captureRuntimeInstanceCreated({ - transport: 'langgraph', - provider: 'openai', - model: 'gpt-4.1', - angularVersion: '21.1.0', -}); -``` - -The `RuntimeInstanceTelemetry` type includes `apiKey`, but the adapter strips it before sending. Do not pass secrets as telemetry properties anyway. - -Use `captureRuntimeRequestCreated()` when a runtime issues a request. The `RuntimeRequestTelemetry` type requires `transport` and `requestType`; `provider` and `model` are optional. - -```ts -await captureRuntimeRequestCreated({ - transport: 'langgraph', - requestType: 'stream', - provider: 'openai', - model: 'gpt-4.1', -}); -``` - -## Capture streams - -```ts -await captureStreamStarted({ - provider: 'openai', - model: 'gpt-4.1', -}); - -await captureStreamEnded({ - provider: 'openai', - model: 'gpt-4.1', - durationMs: 1200, -}); - -await captureStreamErrored({ - provider: 'openai', - model: 'gpt-4.1', - error, -}); -``` - -`captureStreamErrored()` records an error class. It doesn't send the full error object. - -## Ingest and sampling - -`captureEvent()` sends to: - -```text -https://threadplane.ai/api/ingest -``` - -unless `TPLANE_TELEMETRY_INGEST_URL` is set. - -Sampling uses `TPLANE_TELEMETRY_SAMPLE_RATE`. Invalid values fall back to `1`. Values are clamped to the range `0` to `1`. - -Every sent event includes `sample_weight`. - -## Failure behavior - -The Node adapter helpers catch errors and return without throwing. `captureEvent()` returns: - -```ts -type CaptureResult = - | { sent: true } - | { sent: false; reason: 'disabled' | 'sampled' | 'failed' }; -``` - -Use the result in tests or diagnostics. Don't make application correctness depend on telemetry delivery. - -### Asserting the disabled path - -Because `captureEvent()` returns a `CaptureResult`, you can assert that opting out actually short-circuits delivery. Call `disableTelemetry()` first, then check the result: - -```ts -import { describe, expect, it } from 'vitest'; -import { captureEvent, disableTelemetry } from '@threadplane/telemetry/node'; - -describe('telemetry opt-out', () => { - it('does not send when disabled', async () => { - disableTelemetry(); - - const result = await captureEvent('tplane:runtime_instance_created', { transport: 'langgraph' }); - - expect(result).toEqual({ sent: false, reason: 'disabled' }); - }); -}); -``` - -`disableTelemetry()` sets a process-wide flag, so a test that asserts the enabled path must run in a process where it was never called. diff --git a/apps/website/content/docs/telemetry/guides/privacy-and-opt-out.mdx b/apps/website/content/docs/telemetry/guides/privacy-and-opt-out.mdx deleted file mode 100644 index 8c587c0c1..000000000 --- a/apps/website/content/docs/telemetry/guides/privacy-and-opt-out.mdx +++ /dev/null @@ -1,75 +0,0 @@ -# Privacy and Opt-Out - -The telemetry source has two distinct privacy postures. - -Browser telemetry is off unless the app opts in. Node telemetry sends only after an explicit capture call, and the environment or process can still disable it. - -## Node opt-out - -Any of these environment signals disables Node telemetry: - -```bash -DO_NOT_TRACK=1 -npm_config_do_not_track=true -NPM_CONFIG_DO_NOT_TRACK=true -TPLANE_TELEMETRY_DISABLED=1 -CI=true -GITHUB_ACTIONS=true -CONTINUOUS_INTEGRATION=true -BUILDKITE=true -CIRCLECI=true -``` - -The boolean parser treats `1`, `true`, `TRUE`, and `yes` as true values. - -You can also disable telemetry in process: - -```ts -import { disableTelemetry } from '@threadplane/telemetry/node'; - -disableTelemetry(); -``` - -`getDisableReason()` reports one of: - -```ts -'DO_NOT_TRACK' | 'TPLANE_TELEMETRY_DISABLED' | 'CI' | null -``` - -## Browser opt-in - -Browser capture requires an enabled config: - -```ts -provideThreadplaneTelemetry({ enabled: true, sink }); -``` - -With `enabled: false` or no provider, browser capture no-ops. - -The browser service sends only when application code or framework browser code calls its capture methods. It never installs a global listener. - -## Anonymous IDs - -Node uses `anon_` from `node:crypto` and caches it for the current process. - -Browser endpoint delivery uses `browser:` when `crypto.randomUUID()` is available, with a `Math.random()` fallback. The value is kept in the service instance. - -The source never persists either ID across process restarts or browser sessions. - -## Data minimization from source - -Runtime lifecycle helpers send transport/provider/model style metadata. Stream error helpers send an error class, not the raw error object. - -The source strips `apiKey` in the Node runtime adapter before sending. - -No source path sends prompts, completions, message content, tool call arguments, tool call outputs, or environment variable dumps. - -## Custom ingest - -Set a custom Node ingest URL with: - -```bash -TPLANE_TELEMETRY_INGEST_URL=https://telemetry.example.com/api/ingest -``` - -For browser apps, prefer `sink` or `endpoint` so telemetry remains inside your application's analytics boundary. diff --git a/apps/website/content/docs/telemetry/reference/events.mdx b/apps/website/content/docs/telemetry/reference/events.mdx deleted file mode 100644 index 23f047bb6..000000000 --- a/apps/website/content/docs/telemetry/reference/events.mdx +++ /dev/null @@ -1,90 +0,0 @@ -# Telemetry Events - -The shared event union is: - -```ts -type ThreadplaneEvent = ThreadplaneNodeEvent | ThreadplaneBrowserEvent; -``` - -## Node events - -```ts -type ThreadplaneNodeEvent = - | 'tplane:runtime_instance_created' - | 'tplane:runtime_request_created' - | 'tplane:stream_started' - | 'tplane:stream_ended' - | 'tplane:stream_errored'; -``` - -| Event | Source | Properties from source | -| ------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `tplane:runtime_instance_created` | Node adapter helper | `transport`, `provider`, `model`, `angularVersion`; `apiKey` is removed | -| `tplane:runtime_request_created` | Node adapter helper | `transport`, `requestType`, `provider`, `model` (`provider` and `model` optional) | -| `tplane:stream_started` | Node adapter helper | `provider`, `model`, optional fields in the input object | -| `tplane:stream_ended` | Node adapter helper | `provider`, `model`, `durationMs` when supplied | -| `tplane:stream_errored` | Node adapter helper | stream properties plus `errorClass` | - -`captureEvent()` also adds `sample_weight` to sent event properties. - -## Browser events - -The shared event file lists these browser-only events: - -```ts -type ThreadplaneBrowserEvent = 'tplane:browser_provided' | 'tplane:browser_chat_init'; -``` - -The browser Angular token broadens the local service event type to: - -```ts -type ThreadplaneTelemetryEvent = - | 'tplane:browser_provided' - | 'tplane:browser_chat_init' - | 'tplane:runtime_instance_created' - | 'tplane:runtime_request_created' - | 'tplane:stream_started' - | 'tplane:stream_ended' - | 'tplane:stream_errored'; -``` - -That means browser code can capture the browser-specific events plus runtime lifecycle events when telemetry is enabled. - -## Browser payloads - -Endpoint delivery sends: - -```json -{ - "event": "tplane:stream_ended", - "distinctId": "browser:", - "properties": { - "transport": "langgraph", - "provider": "openai", - "model": "gpt-4.1", - "durationMs": 1200, - "sample_weight": 1 - } -} -``` - -When using `sink`, the sink receives the same `event` and `properties` values before endpoint formatting. - -## Node payloads - -Node delivery sends: - -```json -{ - "key": "phc_public_cacheplane_telemetry", - "distinctId": "anon_", - "event": "tplane:stream_started", - "properties": { - "provider": "openai", - "model": "gpt-4.1", - "sample_weight": 1 - } -} -``` - -The public ingest key is a routing identifier accepted by the Threadplane ingest proxy. It is not a secret. diff --git a/apps/website/e2e/docs.spec.ts b/apps/website/e2e/docs.spec.ts index 03801fd28..bd3ad5f32 100644 --- a/apps/website/e2e/docs.spec.ts +++ b/apps/website/e2e/docs.spec.ts @@ -37,8 +37,8 @@ test.describe('Docs landing page', () => { await expect(page.locator('main a[href="/docs/choosing-an-adapter"]').first()).toBeVisible(); await expect(page.locator('main a[href="/docs/render/concepts/json-render-vs-a2ui"]').first()).toBeVisible(); - // Supporting libraries - await expect(page.locator('main a[href="/docs/telemetry/getting-started/introduction"]').first()).toBeVisible(); + // The retired telemetry library must not reappear as a card. + await expect(page.locator('main a[href^="/docs/telemetry"]')).toHaveCount(0); // Search prompt await expect(page.getByText('Looking for something specific?').first()).toBeVisible(); @@ -192,3 +192,20 @@ test.describe('Docs search', () => { await expect(page.getByText('No results found')).toHaveCount(0); }); }); + +test.describe('Retired telemetry docs library', () => { + for (const route of [ + '/docs/telemetry', + '/docs/telemetry/getting-started/introduction', + '/api/markdown/telemetry', + '/api/markdown/telemetry/getting-started/introduction', + ]) { + test(`redirects ${route} to the canonical policy`, async ({ page }) => { + await page.goto(route); + await expect(page).toHaveURL(/\/privacy$/u); + await expect( + page.getByRole('heading', { level: 1, name: /privacy/i }) + ).toBeVisible(); + }); + } +}); diff --git a/apps/website/e2e/public-copy.spec.ts b/apps/website/e2e/public-copy.spec.ts new file mode 100644 index 000000000..ca76fc183 --- /dev/null +++ b/apps/website/e2e/public-copy.spec.ts @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: MIT +import { expect, test, type APIRequestContext } from '@playwright/test'; + +import { + NON_INDEXED_PUBLIC_ROUTES, + RETIRED_ROUTE_PATTERN, + allBarredPatterns, + findBarredCopy, +} from '../src/lib/public-copy-contract'; + +/** + * The public copy boundary, checked against served output rather than source. + * + * The unit scan reads the repository; this reads what a visitor actually + * receives. They can disagree — a template, a generated bundle, or a response + * body can reintroduce a claim that no `.mdx` file contains — and the served + * side is the one that matters. + */ + +async function sitemapRoutes(request: APIRequestContext): Promise { + const response = await request.get('/sitemap.xml'); + expect(response.ok(), '/sitemap.xml must be served').toBe(true); + const xml = await response.text(); + return [...xml.matchAll(/([^<]+)<\/loc>/gu)].map( + (match) => new URL(match[1]).pathname + ); +} + +test.describe('public copy boundary', () => { + test('every indexed page is free of barred claims and retired links', async ({ + request, + }) => { + const routes = await sitemapRoutes(request); + expect(routes.length, 'the sitemap must not be empty').toBeGreaterThan(50); + + const offenders: string[] = []; + for (const route of routes) { + const response = await request.get(route); + expect(response.ok(), `${route} must be served`).toBe(true); + const body = await response.text(); + + for (const hit of findBarredCopy(body)) offenders.push(`${route} — ${hit}`); + if (RETIRED_ROUTE_PATTERN.test(body)) { + offenders.push(`${route} — links a retired documentation route`); + } + } + + expect(offenders).toEqual([]); + }); + + test('non-indexed public routes are checked too', async ({ request }) => { + const offenders: string[] = []; + for (const route of NON_INDEXED_PUBLIC_ROUTES) { + const response = await request.get(route); + expect(response.ok(), `${route} must be served`).toBe(true); + const body = await response.text(); + + for (const hit of findBarredCopy(body)) offenders.push(`${route} — ${hit}`); + if (RETIRED_ROUTE_PATTERN.test(body)) { + offenders.push(`${route} — links a retired documentation route`); + } + } + + expect(offenders).toEqual([]); + }); + + test('public API error bodies carry no product-specific wording', async ({ + request, + }) => { + const malformed = await request.post('/api/ingest', { + headers: { 'content-type': 'application/json' }, + data: '{', + }); + const empty = await request.post('/api/ingest', { + headers: { 'content-type': 'application/json' }, + data: {}, + }); + + for (const response of [malformed, empty]) { + const body = await response.text(); + expect(response.status()).toBeGreaterThanOrEqual(400); + expect(body).not.toMatch(/telemetry/iu); + expect(findBarredCopy(body, allBarredPatterns())).toEqual([]); + } + }); +}); + +test.describe('canonical policy surface', () => { + test('/privacy is the one policy page, reachable from every footer', async ({ + page, + }) => { + await page.goto('/'); + const link = page.locator('footer a[href="/privacy"]'); + await expect(link).toBeVisible(); + + await link.click(); + await expect(page).toHaveURL(/\/privacy$/u); + await expect( + page.getByRole('heading', { level: 1, name: /privacy/i }) + ).toBeVisible(); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( + 'href', + /\/privacy$/u + ); + }); + + test('the policy states retention, deletion, and every processor', async ({ + page, + }) => { + await page.goto('/privacy'); + const main = page.locator('main'); + + await expect(main).toContainText(/indefinite/i); + await expect(main).toContainText(/delet/i); + await expect(main).toContainText('brian@threadplane.ai'); + for (const processor of [ + 'Vercel', + 'Neon', + 'PostHog', + 'Resend', + 'Google', + 'Anthropic', + ]) { + await expect(main).toContainText(processor); + } + }); + + test('docs search surfaces no retired library page', async ({ request }) => { + const response = await request.get('/docs'); + expect(response.ok()).toBe(true); + expect(await response.text()).not.toMatch(RETIRED_ROUTE_PATTERN); + }); + + test.describe('retired routes', () => { + for (const route of [ + '/docs/telemetry', + '/docs/telemetry/getting-started/introduction', + '/docs/telemetry/guides/privacy-and-opt-out', + '/api/markdown/telemetry', + '/api/markdown/telemetry/reference/events', + ]) { + test(`${route} redirects permanently to the policy`, async ({ + request, + }) => { + const response = await request.get(route, { maxRedirects: 0 }); + expect(response.status()).toBe(308); + expect(response.headers()['location']).toContain('/privacy'); + }); + } + }); +}); diff --git a/apps/website/e2e/website.spec.ts b/apps/website/e2e/website.spec.ts index f0a900869..7511c2b2c 100644 --- a/apps/website/e2e/website.spec.ts +++ b/apps/website/e2e/website.spec.ts @@ -37,12 +37,25 @@ test('landing page renders feature blocks (Stream/Render/Ship)', async ({ page } await expect(page.locator('#ship-heading')).toBeVisible(); }); -test('landing page states the all-MIT and explicit-telemetry commitments', async ({ page }) => { +test('landing page no longer carries the retired promises section', async ({ page }) => { await page.goto('/'); const main = page.locator('main'); - await expect(main).toContainText('every package is MIT'); - await expect(main).toContainText('No hidden telemetry'); + await expect(main).not.toContainText("What we won't do"); + await expect(main).not.toContainText('No hidden telemetry'); + await expect(main).not.toContainText('Installation is inert'); +}); + +test('every page links the canonical privacy policy from the footer', async ({ page }) => { + await page.goto('/'); + + const link = page.locator('footer a[href="/privacy"]'); + await expect(link).toBeVisible(); + await link.click(); + await expect(page).toHaveURL(/\/privacy$/u); + await expect( + page.getByRole('heading', { level: 1, name: /privacy/i }) + ).toBeVisible(); }); test('pricing page presents three software and support paths', async ({ page }) => { @@ -397,7 +410,9 @@ test('/llms-full.txt includes generated API reference content', async ({ request expect(body).toContain('### a2ui'); expect(body).toContain('### langgraph'); expect(body).toContain('### chat'); - expect(body).toContain('### telemetry'); + // The dedicated telemetry docs library is retired, so its generated API + // section no longer ships in the public bundle. + expect(body).not.toContain('### telemetry'); expect(body).not.toContain('API reference not yet generated'); }); diff --git a/apps/website/next.config.spec.ts b/apps/website/next.config.spec.ts index 895f1977b..735a96532 100644 --- a/apps/website/next.config.spec.ts +++ b/apps/website/next.config.spec.ts @@ -15,3 +15,43 @@ describe('website next.config rewrites', () => { expect(apiRule.destination).toBe('https://us.i.posthog.com/:path*'); }); }); + +/** + * The dedicated telemetry docs library is retired in favour of one canonical + * policy. Delivered links and search results outlive a deletion, so every + * retired path — both the public routes and the markdown API that mirrors + * them — has to land somewhere real rather than 404. + */ +describe('website next.config redirects', () => { + const retired = [ + '/docs/telemetry', + '/docs/telemetry/:path*', + '/api/markdown/telemetry', + '/api/markdown/telemetry/:path*', + ]; + + it('permanently redirects every retired telemetry route to the policy', async () => { + expect(typeof config.redirects).toBe('function'); + const redirects = await config.redirects!(); + + for (const source of retired) { + const rule = redirects.find( + (r: { source: string }) => r.source === source + ); + expect(rule, `missing redirect for ${source}`).toBeTruthy(); + expect(rule.destination).toBe('/privacy'); + expect(rule.permanent).toBe(true); + } + }); + + it('redirects the exact roots as well as their descendants', async () => { + const sources = (await config.redirects!()).map( + (r: { source: string }) => r.source + ); + + for (const base of ['/docs/telemetry', '/api/markdown/telemetry']) { + expect(sources).toContain(base); + expect(sources).toContain(`${base}/:path*`); + } + }); +}); diff --git a/apps/website/next.config.ts b/apps/website/next.config.ts index d448ea2d9..aad1ff014 100644 --- a/apps/website/next.config.ts +++ b/apps/website/next.config.ts @@ -20,6 +20,19 @@ export const nextConfig: WithNxOptions = { ], }, skipTrailingSlashRedirect: true, + // The dedicated telemetry docs library is retired in favour of the single + // canonical policy. Delivered links and indexed search results outlive the + // deletion, so every retired path lands on /privacy rather than a 404. + redirects: async () => [ + { source: '/docs/telemetry', destination: '/privacy', permanent: true }, + { source: '/docs/telemetry/:path*', destination: '/privacy', permanent: true }, + { source: '/api/markdown/telemetry', destination: '/privacy', permanent: true }, + { + source: '/api/markdown/telemetry/:path*', + destination: '/privacy', + permanent: true, + }, + ], rewrites: async () => [ { source: '/ingest/static/:path*', diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index 56b8273ee..2aed767ff 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -13,6 +13,10 @@ export const createWebsitePlaywrightConfig = ( const runtimeURL = 'http://localhost:4300'; const productionSmoke = environment['PRODUCTION_SMOKE'] === 'true'; const bfcacheRuntimeTest = environment['CUSTOM_RUNTIME_BFCACHE'] === 'true'; + // The public-copy gate must read what a visitor receives, and `next dev` + // serves a different bundle than production. This mode serves the already + // completed Nx production build instead. + const productionMode = environment['WEBSITE_E2E_MODE'] === 'production'; const baseURL = environment['BASE_URL'] ?? localURL; const shouldStartLocalServer = !productionSmoke && !environment['BASE_URL']; const reuseExistingServer = @@ -23,13 +27,23 @@ export const createWebsitePlaywrightConfig = ( testMatch: bfcacheRuntimeTest ? '**/custom-runtime-bfcache.spec.ts' : undefined, + // The public-copy gate crawls every sitemap route. Against a prebuilt + // production server that is seconds; against `next dev` each route compiles + // on demand, which is far too slow to belong in the ordinary suite. It runs + // in production mode only, where its answers are the ones that matter. testIgnore: productionSmoke - ? undefined + ? '**/public-copy.spec.ts' + : productionMode + ? [ + '**/platform-production-smoke.spec.ts', + '**/custom-runtime-bfcache.spec.ts', + ] : bfcacheRuntimeTest - ? '**/platform-production-smoke.spec.ts' + ? ['**/platform-production-smoke.spec.ts', '**/public-copy.spec.ts'] : [ '**/platform-production-smoke.spec.ts', '**/custom-runtime-bfcache.spec.ts', + '**/public-copy.spec.ts', ], fullyParallel: true, // Match the cockpit configs: 2 retries on CI to absorb transient Next.js @@ -78,7 +92,14 @@ export const createWebsitePlaywrightConfig = ( webServer: shouldStartLocalServer ? [ { - command: bfcacheRuntimeTest + command: productionMode + ? // `nx serve --configuration=production` runs with the dist + // directory as its cwd, and dist carries no `content/`. Routes + // that read MDX at request time — /blog among them — then serve + // an empty list, so the gate would pass against a page no + // visitor sees. Link the content in before serving. + `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx nx build website --configuration=production --skip-nx-cache && ln -sfn ../../../apps/website/content dist/apps/website/content && npx nx serve website --configuration=production --port=${localPort} --skip-nx-cache` + : bfcacheRuntimeTest ? `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx nx build website --configuration=production --skip-nx-cache && npx nx serve website --configuration=production --port=${localPort} --skip-nx-cache` : `NEXT_PUBLIC_COCKPIT_RUNTIME_BASE_URL='' npx next dev apps/website --hostname ${localHost} --port ${localPort}`, cwd: '../..', @@ -87,7 +108,13 @@ export const createWebsitePlaywrightConfig = ( // Server pages read the growth form policy while rendering, so the // local server carries the switch the deployed environment sets. env: { GROWTH_FORM_POLICY: 'growth_v1' }, - timeout: bfcacheRuntimeTest ? 180_000 : 60_000, + // A production run builds before it serves, which outlasts the + // BFCache budget; each mode gets the time it actually needs. + timeout: productionMode + ? 300_000 + : bfcacheRuntimeTest + ? 180_000 + : 60_000, }, { command: bfcacheRuntimeTest diff --git a/apps/website/scripts/generate-api-docs.ts b/apps/website/scripts/generate-api-docs.ts index 15e23f969..fb117e37f 100644 --- a/apps/website/scripts/generate-api-docs.ts +++ b/apps/website/scripts/generate-api-docs.ts @@ -2,6 +2,10 @@ import { Application, TSConfigReader, ReflectionKind } from 'typedoc'; import fs from 'fs'; import path from 'path'; +import { + assertPublicDocOutput, + projectPublicDocEntries, +} from './public-doc-projection'; interface ApiParam { name: string; @@ -210,16 +214,6 @@ const LIBRARIES: LibraryEntryConfig[] = [ { docSlug: 'ag-ui', entryPoints: ['libs/ag-ui/src/public-api.ts'] }, { docSlug: 'a2ui', entryPoints: ['libs/a2ui/src/index.ts'] }, { docSlug: 'middleware', entryPoints: ['libs/middleware/src/langgraph/index.ts'] }, - { - docSlug: 'telemetry', - entryPoints: [ - 'libs/telemetry/src/index.ts', - 'libs/telemetry/src/browser/public-api.ts', - 'libs/telemetry/src/node/index.ts', - 'libs/telemetry/src/shared/public-api.ts', - ], - tsconfig: 'libs/telemetry/tsconfig.spec.json', - }, ]; async function generateForLibrary(cfg: LibraryEntryConfig): Promise { @@ -248,9 +242,22 @@ async function generateForLibrary(cfg: LibraryEntryConfig): Promise { const entries = collectApiEntries(project.children ?? []); + // TSDoc is written as code comments but published as copy. Fail on the raw + // entries first: a barred claim in a doc comment also ships in the .d.ts and + // in every IDE tooltip, and the projection below cannot reach either. Silently + // cleaning the website would hide the claim rather than remove it, so the + // author is sent back to the source. + assertPublicDocOutput(`${cfg.docSlug} (source TSDoc)`, JSON.stringify(entries)); + + // Then project and check again, so anything the patterns above phrase + // differently still cannot reach the published file. + const publicEntries = projectPublicDocEntries(entries); + const serialized = JSON.stringify(publicEntries, null, 2); + assertPublicDocOutput(cfg.docSlug, serialized); + fs.mkdirSync(outDir, { recursive: true }); - fs.writeFileSync(path.join(outDir, 'api-docs.json'), JSON.stringify(entries, null, 2)); - console.log(`✓ ${cfg.docSlug}/api/api-docs.json (${entries.length} entries)`); + fs.writeFileSync(path.join(outDir, 'api-docs.json'), serialized); + console.log(`✓ ${cfg.docSlug}/api/api-docs.json (${publicEntries.length} entries)`); } function findPackageRoot(entryPoint: string): string { diff --git a/apps/website/scripts/generate-narrative-docs.ts b/apps/website/scripts/generate-narrative-docs.ts index dd9e82f89..4e0e3da9c 100644 --- a/apps/website/scripts/generate-narrative-docs.ts +++ b/apps/website/scripts/generate-narrative-docs.ts @@ -1,6 +1,17 @@ import Anthropic from '@anthropic-ai/sdk'; import fs from 'fs'; import path from 'path'; +import { assertPublicDocOutput } from './public-doc-projection'; + +/** The six libraries whose generated API docs the website publishes. */ +const PUBLIC_API_LIBRARIES = [ + 'a2ui', + 'ag-ui', + 'chat', + 'langgraph', + 'middleware', + 'render', +] as const; const client = new Anthropic(); const MODEL = process.env['ANTHROPIC_MODEL'] ?? 'claude-sonnet-4-6'; @@ -53,12 +64,15 @@ function loadApiDocs(): string { throw new Error('Run generate-api-docs first: npx tsx apps/website/scripts/generate-api-docs.ts'); } - const sections = fs.readdirSync(API_DOCS_ROOT) - .sort() + // An allowlist, not a directory walk: a retired library that reappears on + // disk must not silently feed the narrative generator again. + const sections = PUBLIC_API_LIBRARIES .map((library) => { const apiDocsPath = path.join(API_DOCS_ROOT, library, 'api', 'api-docs.json'); if (!fs.existsSync(apiDocsPath)) return null; - return `### ${library}\n\n${fs.readFileSync(apiDocsPath, 'utf8')}`; + const source = fs.readFileSync(apiDocsPath, 'utf8'); + assertPublicDocOutput(library, source); + return `### ${library}\n\n${source}`; }) .filter((section): section is string => section !== null); diff --git a/apps/website/scripts/public-doc-projection.spec.ts b/apps/website/scripts/public-doc-projection.spec.ts new file mode 100644 index 000000000..ff16f624d --- /dev/null +++ b/apps/website/scripts/public-doc-projection.spec.ts @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; + +import { + BLOCKED_PUBLIC_CLAIMS, + assertPublicDocOutput, + projectPublicDocEntries, +} from './public-doc-projection'; + +/** + * The generator reads TSDoc straight out of the libraries, so a claim written + * in a doc comment becomes published copy without anyone reviewing it as copy. + * This projection is the boundary: names, types, and structure pass through + * untouched, and only the barred sentences are dropped. + */ +const entries = () => [ + { + kind: 'interface', + name: 'AgentConfig', + description: 'Runtime configuration for the agent.', + examples: ['const config: AgentConfig = { url };'], + properties: [ + { name: 'url', type: 'string', description: 'Endpoint URL.' }, + { + name: 'telemetry', + type: 'false | AgentRuntimeTelemetrySink', + description: + 'Optional app-owned telemetry sink. No telemetry is emitted unless this is provided.', + optional: true, + }, + ], + }, + { + kind: 'interface', + name: 'AgentRuntimeTelemetrySink', + description: 'Sink that receives runtime lifecycle events.', + properties: [{ name: 'emit', type: '(event) => void' }], + }, +]; + +describe('projectPublicDocEntries', () => { + it('keeps the entry, property, and type names the API actually uses', () => { + const projected = projectPublicDocEntries(entries()); + + expect(projected.map((entry) => entry.name)).toEqual([ + 'AgentConfig', + 'AgentRuntimeTelemetrySink', + ]); + const config = projected[0] as { properties: { name: string; type: string }[] }; + expect(config.properties.map((property) => property.name)).toEqual([ + 'url', + 'telemetry', + ]); + expect(config.properties[1].type).toBe('false | AgentRuntimeTelemetrySink'); + }); + + it('drops only the barred sentence from a description', () => { + const projected = projectPublicDocEntries(entries()); + const config = projected[0] as { + properties: { description?: string }[]; + }; + + expect(config.properties[1].description).toBe( + 'Optional app-owned telemetry sink.' + ); + }); + + it('leaves unrelated descriptions and examples exactly as written', () => { + const projected = projectPublicDocEntries(entries()); + const config = projected[0] as { + description: string; + examples: string[]; + properties: { description?: string }[]; + }; + + expect(config.description).toBe('Runtime configuration for the agent.'); + expect(config.examples).toEqual(['const config: AgentConfig = { url };']); + expect(config.properties[0].description).toBe('Endpoint URL.'); + }); + + it('does not mutate its input', () => { + const input = entries(); + const snapshot = JSON.stringify(input); + + projectPublicDocEntries(input); + + expect(JSON.stringify(input)).toBe(snapshot); + }); + + it.each(BLOCKED_PUBLIC_CLAIMS.map((pattern) => [String(pattern), pattern]))( + 'strips %s wherever it appears in a description', + (_label, pattern) => { + const projected = projectPublicDocEntries([ + { + kind: 'interface', + name: 'Example', + description: `Leading sentence. ${ + pattern.source.includes('telemetry') + ? 'No telemetry is emitted unless this is provided.' + : 'Installation is inert.' + }`, + properties: [], + }, + ]); + + expect(JSON.stringify(projected)).not.toMatch(pattern); + } + ); +}); + +describe('assertPublicDocOutput', () => { + it('accepts serialized output with no barred claim', () => { + expect(() => + assertPublicDocOutput('chat', JSON.stringify(projectPublicDocEntries(entries()))) + ).not.toThrow(); + }); + + it('throws when a barred claim survives into serialized output', () => { + expect(() => + assertPublicDocOutput('chat', JSON.stringify(entries())) + ).toThrow(/chat/u); + }); + + it('names the offending claim so the failure is actionable', () => { + expect(() => + assertPublicDocOutput('ag-ui', 'Installation is inert.') + ).toThrow(/installation is inert/iu); + }); +}); diff --git a/apps/website/scripts/public-doc-projection.ts b/apps/website/scripts/public-doc-projection.ts new file mode 100644 index 000000000..60f2f2dd2 --- /dev/null +++ b/apps/website/scripts/public-doc-projection.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +/** + * Claims that must not reach public output. + * + * Each asserts something absolute about behavior that nothing keeps true, and + * a published guarantee that quietly stops holding is worse than none. They + * are barred from generated docs for the same reason they are barred from + * hand-authored copy — see `src/lib/public-copy.spec.ts`. + * + * Sentence-anchored so a whole claim is removed rather than leaving a fragment. + * No trailing lookahead: in serialized JSON a sentence ends at a quote, not at + * whitespace, and the backstop below has to match there too. + */ +export const BLOCKED_PUBLIC_CLAIMS: readonly RegExp[] = [ + /\s*\bNo telemetry is emitted[^.]*\./giu, + /\s*\bInstallation is inert[^.]*\./giu, + /\s*\b[^.]*\bphon(?:e|ing) home[^.]*\./giu, + /\s*\b[^.]*\boff by default[^.]*\./giu, + /\s*\bWe (?:never|do not) collect[^.]*\./giu, +]; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +function stripClaims(value: string): string { + let result = value; + for (const pattern of BLOCKED_PUBLIC_CLAIMS) { + result = result.replace(new RegExp(pattern.source, pattern.flags), ''); + } + return result.trim(); +} + +function project(value: JsonValue): JsonValue { + if (typeof value === 'string') return stripClaims(value); + if (Array.isArray(value)) return value.map(project); + if (value !== null && typeof value === 'object') { + const copy: { [key: string]: JsonValue } = {}; + for (const [key, entry] of Object.entries(value)) { + copy[key] = project(entry); + } + return copy; + } + return value; +} + +/** + * Project generated TypeDoc entries into what the website may publish. + * + * Names, types, structure, and every other description survive verbatim: this + * is a copy boundary, not an API boundary. Renaming `AgentRuntimeTelemetry*`, + * a config field, or a package export would make the published docs disagree + * with the shipped types, which is a worse failure than an unwanted sentence. + * + * Pure — the input is never mutated. + */ +export function projectPublicDocEntries(entries: readonly Entry[]): Entry[] { + return project( + JSON.parse(JSON.stringify(entries)) as JsonValue + ) as unknown as Entry[]; +} + +/** + * Fail the generator rather than write output carrying a barred claim. + * + * The projection above is the intended path; this is the backstop for a claim + * phrased in a way the patterns above only partially match, so the failure + * lands at generation time instead of on the published site. + */ +export function assertPublicDocOutput(label: string, serialized: string): void { + for (const pattern of BLOCKED_PUBLIC_CLAIMS) { + const match = serialized.match(new RegExp(pattern.source, pattern.flags)); + if (match) { + throw new Error( + `${label}: generated public docs contain a barred claim: ${match[0].trim()}` + ); + } + } +} diff --git a/apps/website/src/app/ag-ui/page.tsx b/apps/website/src/app/ag-ui/page.tsx index fbf35aad2..71bf7626e 100644 --- a/apps/website/src/app/ag-ui/page.tsx +++ b/apps/website/src/app/ag-ui/page.tsx @@ -72,7 +72,6 @@ export default async function AgUiPage() { headline="The adapter is the only part that speaks AG-UI" body="Everything above the seam is plain Angular — signals in, components out. toAgent() keeps the protocol at the boundary, so nothing in your UI changes when the backend does." highlight="ag-ui" - caption="Four of the seven AG-UI runtimes shown; the rest speak the same protocol." /> { expect(response.headers.get('access-control-allow-origin')).toBe('*'); }); }); + +/** + * The response bodies are public output; the log prefix and internal type names + * are not. These pin the split so a future rename cannot leak one into the + * other, in either direction. + */ +const INGEST_ROUTE_SOURCE = join( + dirname(fileURLToPath(import.meta.url)), + 'route.ts' +); + +describe('/api/ingest public response copy', () => { + it.each([ + ['Invalid event payload'], + ['Event ingest is not configured'], + ['Event ingest failed'], + ])('uses %s rather than product-specific wording', (expected) => { + const source = readFileSync(INGEST_ROUTE_SOURCE, 'utf8'); + expect(source).toContain(expected); + }); + + it('keeps the internal log prefix untouched', () => { + const source = readFileSync(INGEST_ROUTE_SOURCE, 'utf8'); + expect(source).toContain('[telemetry-ingest]'); + }); + + it('exposes no product-specific wording in a response body', () => { + const source = readFileSync(INGEST_ROUTE_SOURCE, 'utf8'); + for (const [, body] of source.matchAll(/error: '([^']+)'/gu)) { + expect(body).not.toMatch(/telemetry/iu); + } + }); +}); diff --git a/apps/website/src/app/api/ingest/route.ts b/apps/website/src/app/api/ingest/route.ts index 6bb36ffdf..f105e5bf8 100644 --- a/apps/website/src/app/api/ingest/route.ts +++ b/apps/website/src/app/api/ingest/route.ts @@ -73,7 +73,7 @@ export async function POST(req: NextRequest) { const payload = readPayload(body); if (!payload) { return jsonWithCors( - { error: 'Invalid telemetry payload' }, + { error: 'Invalid event payload' }, { status: 400 } ); } @@ -81,7 +81,7 @@ export async function POST(req: NextRequest) { const posthog = getPostHogClient(); if (!posthog) { return jsonWithCors( - { error: 'Telemetry ingest is not configured' }, + { error: 'Event ingest is not configured' }, { status: 503 } ); } @@ -102,7 +102,7 @@ export async function POST(req: NextRequest) { console.error('[telemetry-ingest] capture failed:', err); await posthog.shutdown().catch(() => undefined); return jsonWithCors( - { error: 'Telemetry ingest failed' }, + { error: 'Event ingest failed' }, { status: 502 } ); } diff --git a/apps/website/src/app/docs/page.tsx b/apps/website/src/app/docs/page.tsx index 9ae030669..d88da5d82 100644 --- a/apps/website/src/app/docs/page.tsx +++ b/apps/website/src/app/docs/page.tsx @@ -88,12 +88,6 @@ const SUPPORTING: SupportingLib[] = [ href: '/docs/middleware/getting-started/introduction', glyph: 'middleware', }, - { - title: 'Telemetry', - blurb: 'Browser & Node events', - href: '/docs/telemetry/getting-started/introduction', - glyph: 'pulse', - }, ]; function ChatGlyph() { diff --git a/apps/website/src/app/llms-full.txt/route.ts b/apps/website/src/app/llms-full.txt/route.ts index 403dffd34..4858d80f4 100644 --- a/apps/website/src/app/llms-full.txt/route.ts +++ b/apps/website/src/app/llms-full.txt/route.ts @@ -7,7 +7,6 @@ import agUiApiDocs from '../../../content/docs/ag-ui/api/api-docs.json'; import chatApiDocs from '../../../content/docs/chat/api/api-docs.json'; import middlewareApiDocs from '../../../content/docs/middleware/api/api-docs.json'; import renderApiDocs from '../../../content/docs/render/api/api-docs.json'; -import telemetryApiDocs from '../../../content/docs/telemetry/api/api-docs.json'; const API_DOCS: Record = { a2ui: a2uiApiDocs, @@ -16,7 +15,6 @@ const API_DOCS: Record = { chat: chatApiDocs, middleware: middlewareApiDocs, render: renderApiDocs, - telemetry: telemetryApiDocs, }; function loadApiDocs(): string { diff --git a/apps/website/src/app/llms.txt/route.ts b/apps/website/src/app/llms.txt/route.ts index 98e29d08a..eb1024670 100644 --- a/apps/website/src/app/llms.txt/route.ts +++ b/apps/website/src/app/llms.txt/route.ts @@ -28,7 +28,7 @@ function buildLlmsTxt(): string { '- @threadplane/a2ui — protocol types, JSONL parser, dynamic value resolver, and pointer helpers for A2UI streams', '- @threadplane/middleware — LangGraph.js helpers for browser-executed client tools', '- threadplane-middleware — Python LangGraph helpers for browser-executed client tools', - '- @threadplane/telemetry — browser, Node, and shared telemetry helpers with privacy controls', + '- @threadplane/telemetry — browser, Node, and shared instrumentation helpers', '', '## Install', '# LangGraph backend:', diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index a2d0a76b9..241153c59 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -11,7 +11,6 @@ import { buildPanes } from '../lib/build-panes'; import { PilotBlock } from '../components/landing/PilotBlock'; import { ProofStrip } from '../components/landing/ProofStrip'; import { WhitePaperBlock } from '../components/landing/WhitePaperBlock'; -import { Promises } from '../components/landing/Promises'; import { HomeFAQ } from '../components/landing/HomeFAQ'; import { FinalCTA } from '../components/landing/FinalCTA'; import { RecentArticles } from '../components/landing/RecentArticles'; @@ -46,7 +45,6 @@ export default async function HomePage() { eyebrow="Architecture" headline="Your UI talks to one contract, never to a runtime" body="Your Angular components consume a signal-shaped Agent contract. Adapters implement it — swap the runtime underneath without touching the UI." - caption="The chat surface never imports a runtime SDK — only the contract." /> @@ -137,7 +135,6 @@ export default async function HomePage() { - diff --git a/apps/website/src/app/pilot-to-prod/page.tsx b/apps/website/src/app/pilot-to-prod/page.tsx index de234bef1..8b1c64a94 100644 --- a/apps/website/src/app/pilot-to-prod/page.tsx +++ b/apps/website/src/app/pilot-to-prod/page.tsx @@ -6,7 +6,6 @@ import { Pill } from '../../components/ui/Pill'; import { FeatureBlock } from '../../components/landing/FeatureBlock'; import { BrowserFrame } from '../../components/ui/BrowserFrame'; import { WhitePaperBlock } from '../../components/landing/WhitePaperBlock'; -import { Promises } from '../../components/landing/Promises'; import { FinalCTA } from '../../components/landing/FinalCTA'; import { DiagramSection } from '../../components/landing/DiagramSection'; import { PilotJourney } from '../../components/docs/diagrams'; @@ -126,7 +125,7 @@ export default function PilotToProdPage() { headline="Production-ready, not demo-ready." body="Observability, error boundaries, fallback strategies, deploy paths, on-call runbook. The stuff that makes the difference between a demo and an app you can leave running on a Friday afternoon." rows={[ - { claim: 'Tracing, metrics, error budgets', api: 'OpenTelemetry hooks' }, + { claim: 'Tracing, metrics, error budgets', api: 'Distributed tracing hooks' }, { claim: 'Fallbacks across every agent surface', api: 'readiness + fallback' }, { claim: 'Load tested, on-call ready', api: 'runbook, yours' }, ]} @@ -184,7 +183,6 @@ export default function PilotToProdPage() { - {/* Contact anchor */}
diff --git a/apps/website/src/app/privacy/page.spec.tsx b/apps/website/src/app/privacy/page.spec.tsx new file mode 100644 index 000000000..7b80ea1af --- /dev/null +++ b/apps/website/src/app/privacy/page.spec.tsx @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('../../components/ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../components/ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('../../components/ui/Eyebrow', () => ({ + Eyebrow: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +import PrivacyPage, { metadata } from './page'; + +/** + * One canonical policy replaces the previous scattering of analytics promises. + * These assertions pin what it must say and, just as importantly, what it must + * never grow back into: a per-event catalog, an installation-behavior claim, or + * an absolute guarantee that a future change could quietly falsify. + */ +describe('privacy policy metadata', () => { + it('declares its own canonical path', () => { + expect(metadata.alternates?.canonical).toBe('/privacy'); + }); + + it('carries a title and description', () => { + expect(String(metadata.title)).toMatch(/privacy/i); + expect(String(metadata.description ?? '')).not.toBe(''); + }); +}); + +describe('privacy policy content', () => { + const text = () => { + render(); + return document.body.textContent ?? ''; + }; + + it('names the information Threadplane collects', () => { + const body = text(); + for (const category of [ + /information you submit/i, + /website analytics/i, + /product analytics/i, + ]) { + expect(body).toMatch(category); + } + }); + + it('names every processor that receives data', () => { + const body = text(); + for (const processor of [ + 'Vercel', + 'Neon', + 'PostHog', + 'Resend', + 'Google', + 'Anthropic', + ]) { + expect(body).toContain(processor); + } + }); + + it('states indefinite default retention rather than a fixed window', () => { + expect(text()).toMatch(/indefinite/i); + }); + + it('explains deletion, email opt-out, and how to make contact', () => { + const body = text(); + expect(body).toMatch(/delet/i); + expect(body).toMatch(/unsubscribe|opt out|opt-out/i); + expect(body).toContain('brian@threadplane.ai'); + }); + + it('is reachable as a single heading-led document', () => { + render(); + expect( + screen.getByRole('heading', { level: 1, name: /privacy/i }) + ).toBeTruthy(); + }); + + it.each([ + ['an installation behavior claim', /install/i], + ['a never-collected list', /never collect|we will never|do not collect/i], + ['an absolute guarantee', /\bguarantee/i], + ['a per-event catalog', /event name|event catalog|property name/i], + ])('does not make %s', (_label, pattern) => { + expect(text()).not.toMatch(pattern); + }); +}); diff --git a/apps/website/src/app/privacy/page.tsx b/apps/website/src/app/privacy/page.tsx new file mode 100644 index 000000000..ec3a47af8 --- /dev/null +++ b/apps/website/src/app/privacy/page.tsx @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +import { Container } from '../../components/ui/Container'; +import { Section } from '../../components/ui/Section'; +import { Eyebrow } from '../../components/ui/Eyebrow'; +import { createPageMetadata } from '../../lib/site-metadata'; + +const CONTACT_EMAIL = 'brian@threadplane.ai'; + +export const metadata = createPageMetadata({ + title: 'Privacy — Threadplane', + description: + 'What Threadplane collects, why, who processes it, how long it is kept, and how to opt out or request deletion.', + pathname: '/privacy', + type: 'website', +}); + +/** + * The single canonical statement of what Threadplane collects and why. + * + * It deliberately describes categories and purposes rather than an event or + * property catalog. A catalog would be a maintenance trap: it goes stale the + * first time an event is added, and a stale published catalog is worse than a + * general one. For the same reason this page makes no claim about behavior it + * cannot continuously verify. + */ +export default function PrivacyPage() { + return ( +
+ +
+ Legal +

+ Privacy +

+

+ This page describes what Threadplane collects, why it is collected, + who processes it, and the choices available to you. It applies to + the Threadplane website and to the Threadplane software libraries + and services. +

+ +

Information you submit

+

+ When you request a guide, subscribe to updates, or contact us, we + receive what you enter in the form: your email address and, where + the form asks for them, your name, company, and message. Replies you + send to our email are received and read as ordinary correspondence. +

+ +

Website analytics

+

+ We record how pages are used — pages viewed, referring source, and + broad technical details such as browser and approximate region — to + understand what people find useful. This is used in aggregate. +

+ +

Product analytics

+

+ Threadplane software may report operational facts about how the + product is running so we can see which capabilities work in real + deployments. These reports describe activity, not content. We do not + want and do not seek the substance of what your application is + doing: prompts, messages, tool inputs and outputs, application + state, and source code are outside what this reporting is designed + to carry. +

+ +

How the information is used

+

+ To operate and support the product, to understand and improve how it + is used, to answer you when you get in touch, and — where you have + asked to hear from us — to send relevant email. +

+ +

Who processes it

+

+ Threadplane relies on a small number of service providers who + process data on our behalf: Vercel (hosting), Neon (database), + PostHog (analytics), Resend (email delivery), Google (business email + and calendar), and Anthropic (AI processing used to prepare internal + summaries). Each processes data under its own agreement with us and + for our purposes only. +

+ +

Retention

+

+ Information is retained indefinitely by default, so that account and + correspondence history stays intact. Where you ask us to delete + information, we do so as described below. +

+ +

Your choices

+

+ Every marketing email carries an unsubscribe link, and unsubscribing + stops further marketing email. Replying to ask us to stop has the + same effect. To request deletion of the information we hold about + you, or to ask what that is, email{' '} + {CONTACT_EMAIL} and we will + act on it. Some records are kept where we are required to keep them. +

+ +

Security

+

+ Data is held with the providers named above, protected in transit + and at rest by their standard controls, and access is limited to + those who need it to run the product. +

+ +

International processing

+

+ Our providers operate in the United States and elsewhere, so + information may be processed outside the country where you live. +

+ +

Changes

+

+ When this page changes materially, the change applies from the date + it is published here. +

+ +

Contact

+

+ Questions about this page or about the information we hold:{' '} + {CONTACT_EMAIL}. +

+
+
+
+ ); +} diff --git a/apps/website/src/app/solutions/[slug]/page.tsx b/apps/website/src/app/solutions/[slug]/page.tsx index e50e0ecf1..dadee1b58 100644 --- a/apps/website/src/app/solutions/[slug]/page.tsx +++ b/apps/website/src/app/solutions/[slug]/page.tsx @@ -208,7 +208,6 @@ export default async function SolutionPage({ params }: PageProps) { subtext={solution.ctaSubtext} primary={{ label: 'Talk to us', href: '/pricing#lead-form' }} secondary={{ label: 'Read the docs →', href: '/docs' }} - caption={null} /> ); diff --git a/apps/website/src/components/docs/LibraryMark.tsx b/apps/website/src/components/docs/LibraryMark.tsx index daa7641ea..02946c41e 100644 --- a/apps/website/src/components/docs/LibraryMark.tsx +++ b/apps/website/src/components/docs/LibraryMark.tsx @@ -13,7 +13,6 @@ const MARKS: Record = { render: { kind: 'logo', src: '/logos/surface/vercel.svg' }, chat: { kind: 'glyph', glyph: 'chat' }, middleware: { kind: 'glyph', glyph: 'middleware' }, - telemetry: { kind: 'glyph', glyph: 'pulse' }, runtimes: { kind: 'glyph', glyph: 'layers' }, 'deep-agents': { kind: 'glyph', glyph: 'branch' }, }; diff --git a/apps/website/src/components/landing/FinalCTA.spec.tsx b/apps/website/src/components/landing/FinalCTA.spec.tsx index 7931a831b..bcca68291 100644 --- a/apps/website/src/components/landing/FinalCTA.spec.tsx +++ b/apps/website/src/components/landing/FinalCTA.spec.tsx @@ -38,3 +38,12 @@ describe('FinalCTA', () => { expect(link.getAttribute('rel')).toBeNull(); }); }); + +describe('FinalCTA caption surface', () => { + it('renders no trailing caption', () => { + const { container } = render(); + + expect(container.querySelector('.final-cta-caption')).toBeNull(); + expect(document.body.textContent ?? '').not.toMatch(/installation is inert/i); + }); +}); diff --git a/apps/website/src/components/landing/FinalCTA.tsx b/apps/website/src/components/landing/FinalCTA.tsx index e1c135727..7630edb53 100644 --- a/apps/website/src/components/landing/FinalCTA.tsx +++ b/apps/website/src/components/landing/FinalCTA.tsx @@ -12,8 +12,6 @@ interface FinalCTAProps { primary?: { label: string; href: string; external?: boolean } | null; /** Optional secondary CTA. Defaults to the live Website workspace. */ secondary?: { label: string; href: string; external?: boolean } | null; - /** Optional trailing caption. Defaults to licensing and telemetry line. Pass null to hide. */ - caption?: string | null; /** * 'dark' renders on the dark band. Rule (amended 2026-08-31): dark closes * PRODUCT pages — the homepage (pairing with the Yes wall) and the four @@ -32,7 +30,6 @@ export function FinalCTA({ subtext = 'Install the framework, read the docs, and have a streaming chat in your app this afternoon.', primary = null, secondary = DEFAULT_SECONDARY, - caption = 'All packages are MIT · Production support available · Installation is inert', variant = 'default', }: FinalCTAProps = {}) { return ( @@ -78,11 +75,6 @@ export function FinalCTA({ ) : null} - {caption ? ( -

- {caption} -

- ) : null}
diff --git a/apps/website/src/components/landing/Promises.tsx b/apps/website/src/components/landing/Promises.tsx deleted file mode 100644 index 4230adcdb..000000000 --- a/apps/website/src/components/landing/Promises.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Container } from '../ui/Container'; -import { Section } from '../ui/Section'; -import { Eyebrow } from '../ui/Eyebrow'; - -const PROMISES = [ - { no: 'No runtime lock-in', rest: 'every package is MIT, commercial or not.', tail: 'MIT, all packages' }, - { no: 'No abandoned majors', rest: "Angular's current and previous LTS, always.", tail: 'support policy' }, - { no: 'No required cloud', rest: 'run everything in your own VPC.', tail: 'self-host' }, - { no: 'No hidden telemetry', rest: 'events require an explicit application action.', tail: 'installation is inert' }, - { no: 'No model lock-in', rest: 'swap providers without touching Angular code.', tail: 'any LLM your runtime runs' }, -]; - -export function Promises() { - return ( -
- -
- - Built on principles - -
-

- What we won't do. -

-
- {PROMISES.map((p) => ( -
-

- {p.no} — {p.rest} -

-

{p.tail}

-
- ))} -
-
-
- ); -} diff --git a/apps/website/src/components/landing/YesWall.spec.tsx b/apps/website/src/components/landing/YesWall.spec.tsx index 2ff1603d4..19835002c 100644 --- a/apps/website/src/components/landing/YesWall.spec.tsx +++ b/apps/website/src/components/landing/YesWall.spec.tsx @@ -11,11 +11,11 @@ describe('YesWall', () => { vi.clearAllMocks(); }); - it('renders 16 questions across 4 groups', () => { + it('renders 15 questions across 4 groups', () => { render(); const total = YES_WALL_GROUPS.reduce((n, g) => n + g.rows.length, 0); expect(YES_WALL_GROUPS).toHaveLength(4); - expect(total).toBe(16); + expect(total).toBe(15); for (const group of YES_WALL_GROUPS) { expect(screen.getByText(group.label)).toBeTruthy(); for (const row of group.rows) { @@ -27,7 +27,7 @@ describe('YesWall', () => { it('answers every question Yes', () => { render(); - expect(screen.getAllByText('Yes')).toHaveLength(16); + expect(screen.getAllByText('Yes')).toHaveLength(15); }); it('renders the dark specimen chrome', () => { @@ -45,3 +45,16 @@ describe('YesWall', () => { expect(link.getAttribute('href')).toBe('/docs'); }); }); + +describe('YesWall promise surface', () => { + it('makes no installation or phone-home claim', () => { + render(); + + const wall = document.body.textContent ?? ''; + expect(wall).not.toMatch(/phon(e|ing) home/i); + expect(wall).not.toMatch(/installation is inert/i); + expect( + YES_WALL_GROUPS.flatMap((group) => group.rows).map((row) => row.question) + ).not.toContain('Can I install it without phoning home?'); + }); +}); diff --git a/apps/website/src/components/landing/YesWall.tsx b/apps/website/src/components/landing/YesWall.tsx index 140243ae4..927c1f04f 100644 --- a/apps/website/src/components/landing/YesWall.tsx +++ b/apps/website/src/components/landing/YesWall.tsx @@ -49,7 +49,6 @@ export const YES_WALL_GROUPS: readonly YesGroup[] = [ { question: 'Can I unit-test components that depend on an agent?', api: 'provideFakeAgent' }, { question: 'Can I run all of it inside my own VPC?', api: 'self-host, no runtime SaaS' }, { question: 'Can I use every package commercially without a license fee?', api: 'MIT, all packages' }, - { question: 'Can I install it without phoning home?', api: 'installation is inert' }, ], }, ]; diff --git a/apps/website/src/components/shared/Footer.spec.tsx b/apps/website/src/components/shared/Footer.spec.tsx index ce0e0a105..c83e9eb85 100644 --- a/apps/website/src/components/shared/Footer.spec.tsx +++ b/apps/website/src/components/shared/Footer.spec.tsx @@ -127,3 +127,13 @@ describe('Footer newsletter growth policy', () => { expect(screen.queryByText(/subscribed/i)).toBeNull(); }); }); + +describe('Footer legal navigation', () => { + it('links the canonical privacy policy from the bottom bar', () => { + render(