Skip to content

Commit b77f8f6

Browse files
authored
fix(growth): validate telemetry and align PostHog dashboards (#1050)
1 parent 625a429 commit b77f8f6

38 files changed

Lines changed: 981 additions & 170 deletions

apps/website/src/app/api/ingest/route.spec.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,66 @@ describe('/api/ingest', () => {
7070
expect(response.status).toBe(400);
7171
expect(response.headers.get('access-control-allow-origin')).toBe('*');
7272
});
73+
74+
it.each([
75+
{ event: 'tplane:invented', properties: { transport: 'custom' } },
76+
{ event: 'tplane:postinstall', properties: {} },
77+
{ event: 'tplane:stream_started', properties: {} },
78+
{ event: 'tplane:stream_started', properties: 'secret' },
79+
{ event: 'tplane:stream_started', properties: ['secret'] },
80+
{ event: 'tplane:stream_started', properties: { transport: 1 } },
81+
{ event: 'tplane:browser_chat_init', properties: {} },
82+
{ event: 'tplane:stream_ended', properties: { transport: 'custom', durationMs: -1 } },
83+
])('rejects malformed public payloads without capturing or echoing input', async (payload) => {
84+
const response = await POST(new Request('https://threadplane.ai/api/ingest', {
85+
method: 'POST', body: JSON.stringify({ distinctId: 'test', ...payload }),
86+
}) as never);
87+
expect(response.status).toBe(400);
88+
expect(response.headers.get('access-control-allow-origin')).toBe('*');
89+
expect(await response.json()).toEqual({ error: 'Invalid event payload' });
90+
expect(capture).not.toHaveBeenCalled();
91+
});
92+
93+
it('accepts canonical runtime events while excluding arbitrary fields and person profiles', async () => {
94+
const response = await POST(new Request('https://threadplane.ai/api/ingest', {
95+
method: 'POST', body: JSON.stringify({
96+
distinctId: 'browser:test', event: 'tplane:stream_ended',
97+
properties: {
98+
transport: 'langgraph', surface: 'canonical_demo', durationMs: 120,
99+
'0': 'private', command: 'private', body: 'private', token: 'private',
100+
$set: { email: 'private' }, $ip: '1.2.3.4', $process_person_profile: true,
101+
},
102+
}),
103+
}) as never);
104+
expect(response.status).toBe(202);
105+
expect(capture).toHaveBeenCalledWith({
106+
distinctId: 'browser:test', event: 'tplane:stream_ended',
107+
properties: { transport: 'langgraph', surface: 'canonical_demo', durationMs: 120, $ip: null, $process_person_profile: false },
108+
});
109+
});
110+
111+
it('rejects an oversized streamed body even without content-length', async () => {
112+
const response = await POST(new Request('https://threadplane.ai/api/ingest', {
113+
method: 'POST', body: JSON.stringify({ distinctId: 'test', event: 'tplane:browser_provided', properties: { body: 'x'.repeat(16_384) } }),
114+
}) as never);
115+
expect(response.status).toBe(413);
116+
expect(response.headers.get('access-control-allow-origin')).toBe('*');
117+
expect(capture).not.toHaveBeenCalled();
118+
});
119+
120+
it('reports provider failure without logging the raw exception', async () => {
121+
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
122+
shutdown.mockRejectedValueOnce(new Error('private provider response'));
123+
try {
124+
const response = await POST(new Request('https://threadplane.ai/api/ingest', {
125+
method: 'POST', body: JSON.stringify({ distinctId: 'test', event: 'tplane:browser_provided', properties: {} }),
126+
}) as never);
127+
expect(response.status).toBe(502);
128+
expect(log).toHaveBeenCalledWith('[telemetry-ingest] capture failed');
129+
} finally {
130+
log.mockRestore();
131+
}
132+
});
73133
});
74134

75135
/**

apps/website/src/app/api/ingest/route.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { PostHog } from 'posthog-node';
22
import { NextRequest, NextResponse } from 'next/server';
3-
import { normalizePostHogHost, toSafeAnalyticsString } from '@threadplane/telemetry/shared';
3+
import { normalizePostHogHost, parseTelemetryEvent, toSafeAnalyticsString } from '@threadplane/telemetry/shared';
4+
import { readBoundedBody } from '../_internal/read-bounded-body';
45

56
const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry';
7+
const MAX_BODY_BYTES = 16_384;
68
const CORS_HEADERS = {
79
'Access-Control-Allow-Origin': '*',
810
'Access-Control-Allow-Methods': 'POST, OPTIONS',
@@ -41,13 +43,13 @@ function readPayload(value: unknown): {
4143
if (payload.key !== undefined && payload.key !== PUBLIC_INGEST_KEY) return null;
4244

4345
const distinctId = toSafeAnalyticsString(payload.distinctId, 200);
44-
const event = toSafeAnalyticsString(payload.event, 100);
45-
if (!distinctId || !event?.startsWith('tplane:')) return null;
46+
const parsed = parseTelemetryEvent(payload.event, payload.properties);
47+
if (!distinctId || !parsed) return null;
4648

4749
return {
4850
distinctId,
49-
event,
50-
properties: isRecord(payload.properties) ? payload.properties : {},
51+
event: parsed.event,
52+
properties: parsed.properties,
5153
};
5254
}
5355

@@ -65,7 +67,9 @@ export function OPTIONS(): NextResponse {
6567
export async function POST(req: NextRequest) {
6668
let body: unknown;
6769
try {
68-
body = await req.json();
70+
const rawBody = await readBoundedBody(req, MAX_BODY_BYTES);
71+
if (rawBody === null) return jsonWithCors({ error: 'Invalid request body' }, { status: 413 });
72+
body = JSON.parse(rawBody);
6973
} catch {
7074
return jsonWithCors({ error: 'Invalid JSON' }, { status: 400 });
7175
}
@@ -98,8 +102,8 @@ export async function POST(req: NextRequest) {
98102
});
99103
await posthog.shutdown();
100104
return jsonWithCors({ ok: true }, { status: 202 });
101-
} catch (err) {
102-
console.error('[telemetry-ingest] capture failed:', err);
105+
} catch {
106+
console.error('[telemetry-ingest] capture failed');
103107
await posthog.shutdown().catch(() => undefined);
104108
return jsonWithCors(
105109
{ error: 'Event ingest failed' },

docs/growth/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,13 @@ search reporting also have no in-repo recurring worker.
9292

9393
The Growth funnel is an observation/activation report, not a complete sequential
9494
anonymous conversion funnel. A UTM is not a proven link from a social post to a
95-
developer identity. Current PostHog report parsing needs separate improvement for
96-
funnels and multiple trend series; do not treat unsupported or missing data as
97-
proof of zero activity. An analytics contract failure does not itself prove a
98-
lifecycle delivery failure.
95+
developer identity. PostHog's Quick overview separates acquisition, docs, demo
96+
and public runtime signals; install copy attempts do not measure npm installs.
97+
The runtime dashboard includes demo usage and historical malformed events.
98+
The weekly report separates additive daily series and marks funnels, unique
99+
counts, breakdowns and missing results `Unavailable`. An analytics contract
100+
failure does not itself prove a lifecycle delivery failure. See the
101+
[dashboard inventory and measurement limits](../../tools/posthog/README.md#current-growth-dashboards).
99102

100103
## Contributor checks
101104

docs/gtm/taxonomy.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces.
3636
| `marketing:lead_form_submit` | Submit attempt (any surface) |
3737
| `marketing:lead_form_success` | Server 2xx |
3838
| `marketing:lead_form_fail` | Server non-2xx |
39-
| `marketing:lead_qualified` | Server-side enrichment passes (qualified-lead def) |
39+
| `marketing:lead_qualified` | Historical only: retired qualification emitter. Current evidence and authorization live in Growth. |
4040
| `marketing:newsletter_signup_submit` | Submit attempt |
4141
| `marketing:newsletter_signup_success` | Server 2xx |
4242
| `marketing:newsletter_signup_fail` | Failure |
@@ -50,6 +50,18 @@ The standard PostHog `$pageview` event is used as-is across all three surfaces.
5050
| `blog:copy_code_click` | Copy-button click on a code block inside a blog post. Props: `surface: 'blog'`, `code_lang?`. |
5151
| `docs:tab_select` | MDX tab change |
5252
| `docs:sidebar_section_toggle` | Sidebar nav toggle |
53+
| `docs:workspace_navigation` | Workspace capability navigation; `capability`, `category`, `from_capability`, `surface`. |
54+
| `docs:workspace_mode_switched` | Workspace mode change; `capability`, `from_mode`, `to_mode`, `surface`. |
55+
| `docs:workspace_runtime_action` | Explicit runtime action; `capability`, `action`, `state_before`, `outcome`, `surface`. |
56+
| `docs:workspace_runtime_status_changed` | Runtime status transition; `capability`, `from_state`, `to_state`, optional `elapsed_ms`/`reason_code`, `surface`. |
57+
| `marketing:stage_progress` | Recorded homepage stage progress; `surface`, `stage_event`, optional `beat`. Not a live developer runtime. |
58+
59+
Current dashboards distinguish website intent, client-observed form acceptance,
60+
and independent demo milestones. `hero_install` is a copy attempt recorded before
61+
clipboard success, not an npm install. The former six-signal activation funnel
62+
does not represent Growth's install/runtime activation and is no longer managed.
63+
Actual install/runtime activation, enrichment, authorization and email outcomes
64+
remain authoritative in Neon; see [Growth operations](../growth/README.md).
5365

5466
## Cockpit (activation surface)
5567

libs/telemetry/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,25 @@ await captureEvent('tplane:runtime_instance_created', {
151151
The runtime adapter helpers exported from `@threadplane/telemetry/node` are
152152
convenience wrappers around the same explicit capture path.
153153

154+
The Node capture path and Threadplane's public ingest endpoint validate the seven
155+
documented SDK event names at runtime. Runtime events require `transport`;
156+
`tplane:browser_chat_init` requires `surface`. Property bags must be plain objects.
157+
Only `transport`, `surface`, `requestType`, `provider`, `model`, `errorClass`,
158+
`angularVersion`, `durationMs`, and `sample_weight` are forwarded. Strings are
159+
nonempty labels of at most 128 characters without control characters; durations
160+
must be finite numbers from 0 to 86,400,000 milliseconds, and sampling weights
161+
must be finite numbers of at least 1, preserving reciprocal weights at low sample
162+
rates. Unknown properties are dropped; invalid known metadata
163+
rejects the event. Do not put user content or credentials in metadata labels.
164+
Public submissions are untrusted observations, not verified product activity.
165+
166+
`captureEvent()` returns `{ sent: false, reason: 'invalid' }` for invalid inputs.
167+
The Node stream helpers accept an optional `transport`; valid legacy calls with
168+
provider/model but no transport report `unknown`, without guessing an adapter.
169+
Pass the transport explicitly for meaningful transport breakdowns. Generic
170+
`captureEvent()` calls do not receive this fallback. These checks do not change
171+
browser opt-in, custom sinks, or development-only Growth collection controls.
172+
154173
Set `TPLANE_TELEMETRY_INGEST_URL` to route events to an endpoint you control.
155174
The default endpoint is `https://threadplane.ai/api/ingest`.
156175

libs/telemetry/src/node/adapter.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,21 @@ describe('adapter helpers', () => {
7979
vi.mocked(captureEvent).mockRejectedValueOnce(new Error('network'));
8080
await expect(captureStreamStarted({ provider: 'x', model: 'y' })).resolves.toBeUndefined();
8181
});
82+
83+
test.each([captureStreamStarted, captureStreamEnded, captureStreamErrored])('legacy stream helpers identify transport as unknown', async (capture) => {
84+
await capture({ provider: 'openai', model: 'gpt-4', error: new Error('private') });
85+
expect(vi.mocked(captureEvent).mock.calls[0][1]).toMatchObject({ transport: 'unknown' });
86+
});
87+
88+
test.each([captureStreamStarted, captureStreamEnded, captureStreamErrored])('stream helpers preserve an explicit transport', async (capture) => {
89+
await capture({ transport: 'ag-ui', provider: 'openai', model: 'gpt-4', error: new Error('private') } as never);
90+
expect(vi.mocked(captureEvent).mock.calls[0][1]).toMatchObject({ transport: 'ag-ui' });
91+
});
92+
93+
test.each([null, 'private', 42, [], new Date(), {}, { provider: 'openai' }, { provider: '', model: 'gpt-4' }])('stream helpers do not manufacture events from malformed inputs', async (input) => {
94+
await captureStreamStarted(input as never);
95+
await captureStreamEnded(input as never);
96+
await captureStreamErrored(input as never);
97+
expect(captureEvent).not.toHaveBeenCalled();
98+
});
8299
});

libs/telemetry/src/node/adapter.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export interface RuntimeInstanceTelemetry {
99
}
1010

1111
export interface StreamTelemetry {
12+
/** Runtime transport when known; legacy calls without it report `unknown`. */
13+
transport?: string;
1214
provider: string;
1315
model: string;
1416
durationMs?: number;
@@ -25,6 +27,12 @@ async function safe(fn: () => Promise<unknown>): Promise<void> {
2527
try { await fn(); } catch { /* silent fail */ }
2628
}
2729

30+
function streamProperties(input: StreamTelemetry): Record<string, unknown> | null {
31+
if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype) return null;
32+
if (typeof input.provider !== 'string' || !input.provider.trim() || typeof input.model !== 'string' || !input.model.trim()) return null;
33+
return { ...input, transport: input.transport === undefined ? 'unknown' : input.transport };
34+
}
35+
2836
export async function captureRuntimeInstanceCreated(input: RuntimeInstanceTelemetry): Promise<void> {
2937
await safe(async () => {
3038
const { apiKey, ...rest } = input;
@@ -38,19 +46,27 @@ export async function captureRuntimeRequestCreated(input: RuntimeRequestTelemetr
3846
}
3947

4048
export async function captureStreamStarted(input: StreamTelemetry): Promise<void> {
41-
await safe(() => captureEvent('tplane:stream_started', { ...input }));
49+
await safe(async () => {
50+
const properties = streamProperties(input);
51+
if (properties) await captureEvent('tplane:stream_started', properties);
52+
});
4253
}
4354

4455
export async function captureStreamEnded(input: StreamTelemetry): Promise<void> {
45-
await safe(() => captureEvent('tplane:stream_ended', { ...input }));
56+
await safe(async () => {
57+
const properties = streamProperties(input);
58+
if (properties) await captureEvent('tplane:stream_ended', properties);
59+
});
4660
}
4761

4862
export async function captureStreamErrored(
4963
input: StreamTelemetry & { error: Error | unknown },
5064
): Promise<void> {
5165
await safe(async () => {
66+
const properties = streamProperties(input);
67+
if (!properties) return;
5268
const { error, ...rest } = input;
5369
const errorClass = error instanceof Error ? error.constructor.name : 'Unknown';
54-
await captureEvent('tplane:stream_errored', { ...rest, errorClass });
70+
await captureEvent('tplane:stream_errored', { ...rest, transport: properties['transport'], errorClass });
5571
});
5672
}

libs/telemetry/src/node/client.spec.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,13 @@ describe('node client', () => {
3737

3838
test('uses the configured ingest endpoint', async () => {
3939
process.env.TPLANE_TELEMETRY_INGEST_URL = 'https://custom.example/api/ingest';
40-
await captureEvent('tplane:stream_started', {});
40+
await captureEvent('tplane:stream_started', { transport: 'custom' });
4141
expect(fetchMock.mock.calls[0][0]).toBe('https://custom.example/api/ingest');
4242
});
4343

4444
test('defaults to the Threadplane ingest proxy', async () => {
4545
delete process.env.TPLANE_TELEMETRY_INGEST_URL;
46-
await captureEvent('tplane:stream_started', {});
46+
await captureEvent('tplane:stream_started', { transport: 'custom' });
4747
expect(fetchMock.mock.calls[0][0]).toBe('https://threadplane.ai/api/ingest');
4848
});
4949

@@ -67,15 +67,33 @@ describe('node client', () => {
6767

6868
test('reports failed sends instead of throwing', async () => {
6969
fetchMock.mockRejectedValueOnce(new Error('network'));
70-
await expect(captureEvent('tplane:stream_errored', {})).resolves.toEqual({
70+
await expect(captureEvent('tplane:stream_errored', { transport: 'custom' })).resolves.toEqual({
7171
sent: false,
7272
reason: 'failed',
7373
});
7474
});
7575

7676
test('invalid sample rate falls back to sending', async () => {
7777
process.env.TPLANE_TELEMETRY_SAMPLE_RATE = 'not-a-number';
78-
await expect(captureEvent('tplane:stream_started', {})).resolves.toEqual({ sent: true });
78+
await expect(captureEvent('tplane:stream_started', { transport: 'custom' })).resolves.toEqual({ sent: true });
7979
expect(fetchMock).toHaveBeenCalledOnce();
8080
});
81+
82+
test.each([{}, null, 'secret', [], { transport: 42 }, { transport: 'custom', model: { secret: true } }])(
83+
'silently rejects malformed runtime properties before sending', async (properties) => {
84+
await expect(captureEvent('tplane:stream_started', properties as never)).resolves.toEqual({ sent: false, reason: 'invalid' });
85+
expect(fetchMock).not.toHaveBeenCalled();
86+
},
87+
);
88+
89+
test('rejects unknown runtime event names', async () => {
90+
await expect(captureEvent('tplane:invented' as never, { transport: 'custom' })).resolves.toEqual({ sent: false, reason: 'invalid' });
91+
expect(fetchMock).not.toHaveBeenCalled();
92+
});
93+
94+
test('does not forward arbitrary properties or caller-supplied sampling weight', async () => {
95+
await captureEvent('tplane:stream_started', { transport: 'custom', command: 'secret', token: 'secret', sample_weight: 9 });
96+
const body = JSON.parse(String(fetchMock.mock.calls[0][1].body));
97+
expect(body.properties).toEqual({ transport: 'custom', sample_weight: 1 });
98+
});
8199
});

libs/telemetry/src/node/client.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getAnonId } from '../shared/anon-id.js';
22
import { isTelemetryDisabled } from '../shared/env.js';
33
import { shouldSample } from '../shared/sample.js';
44
import type { ThreadplaneNodeEvent } from '../shared/events.js';
5+
import { parseTelemetryEvent } from '../shared/ingest.js';
56
import { isProgrammaticallyDisabled } from './disable.js';
67

78
const DEFAULT_INGEST = 'https://threadplane.ai/api/ingest';
@@ -12,7 +13,7 @@ const PUBLIC_INGEST_KEY = 'phc_public_cacheplane_telemetry';
1213

1314
export type CaptureResult =
1415
| { sent: true }
15-
| { sent: false; reason: 'disabled' | 'sampled' | 'failed' };
16+
| { sent: false; reason: 'disabled' | 'sampled' | 'failed' | 'invalid' };
1617

1718
function getSampleRate(env: NodeJS.ProcessEnv = process.env): number {
1819
const parsed = Number(env.TPLANE_TELEMETRY_SAMPLE_RATE ?? '1');
@@ -42,18 +43,22 @@ export async function captureEvent(
4243
): Promise<CaptureResult> {
4344
if (isTelemetryDisabled() || isProgrammaticallyDisabled())
4445
return { sent: false, reason: 'disabled' };
46+
const parsed = parseTelemetryEvent(event, properties);
47+
if (!parsed || parsed.event.startsWith('tplane:browser_')) return { sent: false, reason: 'invalid' };
4548
const rate = getSampleRate();
4649
const anonId = getAnonId();
4750
if (!shouldSample(rate, anonId)) return { sent: false, reason: 'sampled' };
51+
const payload = parseTelemetryEvent(parsed.event, {
52+
...parsed.properties,
53+
sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1,
54+
});
55+
if (!payload) return { sent: false, reason: 'invalid' };
4856
try {
4957
await postJson(process.env.TPLANE_TELEMETRY_INGEST_URL ?? DEFAULT_INGEST, {
5058
key: PUBLIC_INGEST_KEY,
5159
distinctId: anonId,
52-
event,
53-
properties: {
54-
...properties,
55-
sample_weight: rate > 0 ? 1 / Math.min(1, rate) : 1,
56-
},
60+
event: payload.event,
61+
properties: payload.properties,
5762
});
5863
return { sent: true };
5964
} catch {

0 commit comments

Comments
 (0)