Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
e5e089e
feat(node): distributed tracing spans
turnipdabeets Aug 28, 2026
5f7b2ff
docs(node): regenerate the references for the withSpan doc comment
turnipdabeets Sep 1, 2026
9ca920e
feat(node): bound live spans by count and age
turnipdabeets Sep 1, 2026
b07c9eb
Merge remote-tracking branch 'origin/main' into feat/traces-node-mvp
turnipdabeets Sep 1, 2026
bf5e7af
fix(traces): pass an inbound trace through when tracing is off
turnipdabeets Sep 1, 2026
02cd40a
Merge remote-tracking branch 'origin/main' into feat/traces-node-mvp
turnipdabeets Sep 1, 2026
dc7aed3
fix(traces): count spans dropped at the consent gates
turnipdabeets Sep 1, 2026
92309f4
feat(node): emit os.name and os.version on spans (#4679)
turnipdabeets Sep 2, 2026
b5b0a84
Merge main into feat/traces-node-mvp
turnipdabeets Sep 3, 2026
0c640f2
feat(traces): propagate the inbound sampled flag and parent remotenes…
turnipdabeets Sep 3, 2026
d7ffe61
refactor(traces): keep the traces plumbing off the public API (#4773)
turnipdabeets Sep 4, 2026
44b53a1
fix(traces): reject a traceparent W3C requires a vendor to ignore (#4…
turnipdabeets Sep 4, 2026
24d51e3
fix(traces): keep inbound context and flag a future span start (#4790)
turnipdabeets Sep 4, 2026
9d10ad4
feat(node): beforeSpanSend hook and per-span limits (#4584)
turnipdabeets Sep 4, 2026
815d53d
feat(traces): cap the attributes on a span event
turnipdabeets Sep 4, 2026
4796890
fix(traces): stop a nullish value spending an event's attribute slot
turnipdabeets Sep 4, 2026
1ce3bc3
chore: drop an unrelated changeset committed by mistake
turnipdabeets Sep 4, 2026
701bba2
docs(traces): fold the traces changesets into one, and tidy stray com…
turnipdabeets Sep 4, 2026
8e3573f
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/t…
turnipdabeets Sep 4, 2026
a545925
docs(traces): cut the tracing changeset to a single line
turnipdabeets Sep 4, 2026
94679e7
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/t…
turnipdabeets Sep 4, 2026
6fc61fa
chore: drop the unrelated RN changeset again
turnipdabeets Sep 4, 2026
8c3e21c
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fix/t…
turnipdabeets Sep 4, 2026
a9e76dc
refactor(traces): keep the per-event attribute cap internal
turnipdabeets Sep 4, 2026
833a165
fix(traces): clamp drop counts to uint32 and correct the size comments
turnipdabeets Sep 8, 2026
4027827
docs(traces): mark cross-package plumbing internal and correct the pa…
turnipdabeets Sep 8, 2026
2122d69
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into tmp/4…
turnipdabeets Sep 8, 2026
822383b
Merge remote-tracking branch 'origin/main' into feat/traces-node-mvp
turnipdabeets Sep 8, 2026
3b13d60
Merge branch 'feat/traces-node-mvp' into fix/traces-event-attribute-cap
turnipdabeets Sep 8, 2026
2581b66
fix(traces): keep the inbound trace context through nested spans
turnipdabeets Sep 8, 2026
54705cf
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into fold/…
turnipdabeets Sep 8, 2026
b2aaa72
feat(traces): report a span's limit drops once, event attributes incl…
turnipdabeets Sep 8, 2026
c8f9a66
fix(traces): follow Jon's revised tracestate and parent-doc review
turnipdabeets Sep 8, 2026
ccfc0c9
Merge remote-tracking branch 'origin/feat/traces-node-mvp' into sync/…
turnipdabeets Sep 8, 2026
880b68d
feat(traces): cap the attributes on a span event (#4792)
turnipdabeets Sep 8, 2026
43425f8
docs(changeset): name the span hook and per-span caps that ship with …
turnipdabeets Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/metrics-resource-attribute-getters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-node': patch
'@posthog/core': patch
---

Stop a throwing getter in `metrics.resourceAttributes` from breaking every metrics export — the key is recorded as `[Unserializable]` instead.
7 changes: 7 additions & 0 deletions .changeset/node-distributed-tracing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'posthog-node': minor
'@posthog/core': minor
'@posthog/types': minor
---

Add experimental distributed tracing to `posthog-node`: `startSpan`, `withSpan` and `getActiveSpan` record spans against a new `traces` client option. A service with tracing off still forwards an inbound `traceparent`, including from spans nested inside the one that received it, so a distributed trace is not severed. A `traceparent` may be passed as the one-element array `req.headersDistinct` gives. A `beforeSpanSend` hook sees every span before it is exported and may edit or drop it, and `maxAttributesPerSpan`, `maxEventsPerSpan`, `maxAttributeValueLength`, `maxLiveSpans` and `maxSpanAgeMs` bound what a single span and a single process may hold.
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"lint:fix": "oxlint --report-unused-disable-directives-severity error src --fix",
"build": "rslib build",
"dev": "rslib build -w",
"test:unit": "vitest run",
"test:unit": "NODE_OPTIONS=--expose-gc vitest run",
"package": "pnpm pack --out $PACKAGE_DEST/%s.tgz"
},
"exports": {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/__tests__/posthog.flush.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -761,11 +761,12 @@ describe('PostHog Core', () => {
})

describe('OTLP batch senders', () => {
// Both share one `_sendOtlpBatch`; the table pins them to the same
// All three share one `_sendOtlpBatch`; the table pins them to the same
// classification so a wrapper can't reintroduce a per-signal retry policy.
const senders = {
logs: (client: PostHogCoreTestClient) => client._sendLogsBatch({ resourceLogs: [] }),
metrics: (client: PostHogCoreTestClient) => client._sendMetricsBatch({ resourceMetrics: [] }),
traces: (client: PostHogCoreTestClient) => client._sendTracesBatch({ resourceSpans: [] }),
}

const cases: [number, string][] = [
Expand Down
49 changes: 49 additions & 0 deletions packages/core/src/__tests__/posthog.otlp-auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createTestClient, PostHogCoreTestClient, PostHogCoreTestClientMocks } from '@/testing'

// One `_sendOtlpBatch` serves logs, metrics and traces, and the endpoint each one
// authenticates against is a single argument away from the wrong scheme. These pin
// all three so a flipped argument fails here rather than shipping the project key
// in a header the logs endpoint does not expect.
describe('OTLP batch auth', () => {
let posthog: PostHogCoreTestClient
let mocks: PostHogCoreTestClientMocks

beforeEach(() => {
;[posthog, mocks] = createTestClient('TEST_API_KEY', {
host: 'http://example.com',
preloadFeatureFlags: false,
disableCompression: true,
})
mocks.fetch.mockResolvedValue({
status: 200,
text: () => Promise.resolve('ok'),
json: () => Promise.resolve({ status: 'ok' }),
})
})

const lastCall = (): [string, any] => mocks.fetch.mock.calls[mocks.fetch.mock.calls.length - 1] as [string, any]

it('sends logs to the query-token endpoint with no Authorization header', async () => {
await posthog._sendLogsBatch({ resourceLogs: [] } as any)

const [url, options] = lastCall()
expect(url).toBe('http://example.com/i/v1/logs?token=TEST_API_KEY')
expect(options.headers).not.toHaveProperty('Authorization')
})

it('sends metrics to the query-token endpoint with no Authorization header', async () => {
await posthog._sendMetricsBatch({ resourceMetrics: [] } as any)

const [url, options] = lastCall()
expect(url).toBe('http://example.com/i/v1/metrics?token=TEST_API_KEY')
expect(options.headers).not.toHaveProperty('Authorization')
})

it('sends traces with bearer auth and no token in the query string', async () => {
await posthog._sendTracesBatch({ resourceSpans: [] } as any)

const [url, options] = lastCall()
expect(url).toBe('http://example.com/i/v1/traces')
expect(options.headers.Authorization).toBe('Bearer TEST_API_KEY')
})
})
18 changes: 18 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ export type {
Metrics,
MetricsConfig,
} from './metrics/types'
export { PostHogTraces } from './traces'

@jonmcwest jonmcwest Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: mark cross-package plumbing as @internal.

Please tag PostHogTraces, SyncSpanContextManager, inertSpan, runWithActiveSpan, resolveTracesConfig, ResolvedTracesConfig, and TraceSdkContext. They are exported from the published core package but are not intended as stable public API.

Use the existing cross-package JSDoc wording. Leave SpanContextManager public because it appears in the protected initialization hook. Non-blocking.

Sources: core exports · existing internal convention.

Agent-assisted review.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 40278277f. All seven tagged at the declaration rather than the re-export, SpanContextManager left public. pnpm generate-references is unchanged by it.

export { SyncSpanContextManager } from './traces/context'
export { inertSpan, runWithActiveSpan } from './traces/span'
export { resolveTracesConfig } from './traces/config'
export type { ResolvedTracesConfig, SpanContextManager, TraceSdkContext } from './traces/types'
// The `beforeSpanSend` shapes come straight from @posthog/types: hooks see the
// public record, not core's internal one, which also carries `traceState`.
export type { SpanRecord, BeforeSpanSendFn } from '@posthog/types'
export type {
Span,
SpanAttributes,
SpanAttributeValue,
SpanKind,
SpanStatusCode,
SpanTimeInput,
StartSpanOptions,
TracesConfig,
} from './traces/types'
export { uuidv7 } from './vendor/uuidv7'
export * from './cookie'
export * from './posthog-core'
Expand Down
41 changes: 30 additions & 11 deletions packages/core/src/posthog-core-stateless.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { OtlpLogsPayload, OtlpMetricsPayload } from '@posthog/types'
import type { OtlpLogsPayload, OtlpMetricsPayload, OtlpTracesPayload } from '@posthog/types'
import type { SendMetricsBatchOutcome } from './metrics/types'
import type { SendTracesBatchOutcome } from './traces/types'
import { SimpleEventEmitter } from './eventemitter'
import { getFeatureFlagValue, minimizeFlagCalledEventProperties, normalizeFlagsResponse } from './featureFlagUtils'
import { gzipCompress, isGzipSupported } from './gzip'
Expand Down Expand Up @@ -238,8 +239,8 @@ export type SendLogsBatchOutcome =

/**
* Each signal keeps its own exported outcome type because each belongs to a
* separate host contract. The wrappers return this value directly, so one
* drifting out of shape fails to compile.
* separate host contract. The wrappers return this value directly, so any of
* the three drifting out of shape fails to compile.
*/
type SendOtlpBatchOutcome =
| { kind: 'ok' }
Expand Down Expand Up @@ -1642,9 +1643,9 @@ export abstract class PostHogCoreStateless {
}

/**
* Shared implementation behind the OTLP senders, which differ only in path.
* Returns a tagged outcome instead of throwing so the queue owners don't
* have to know the core's error class hierarchy.
* Shared implementation behind the three OTLP senders, which differ only in
* path and auth style. Returns a tagged outcome instead of throwing so the
* queue owners don't have to know the core's error class hierarchy.
*
* Exhausted 408/429/5xx stay `retry-later`, unlike the events `_flush()`
* which drops anything that isn't a network error: every OTLP queue is
Expand All @@ -1653,24 +1654,30 @@ export abstract class PostHogCoreStateless {
*/
private async _sendOtlpBatch({
path,
auth,
payload,
}: {
path: 'logs' | 'metrics'
payload: OtlpLogsPayload | OtlpMetricsPayload
path: 'logs' | 'metrics' | 'traces'
auth: 'query-token' | 'bearer'
payload: OtlpLogsPayload | OtlpMetricsPayload | OtlpTracesPayload
}): Promise<SendOtlpBatchOutcome> {
if (this.disabled) {
return { kind: 'fatal', error: new Error('The client is disabled') }
}

const serialized = JSON.stringify(payload)
const url = `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}`
const url =
auth === 'bearer'
? `${this.host}/i/v1/${path}`
: `${this.host}/i/v1/${path}?token=${encodeURIComponent(this.apiKey)}`

const gzippedPayload = !this.disableCompression ? await this.compressPayload(serialized) : null
const fetchOptions: PostHogFetchOptions = {
method: 'POST',
headers: {
...this.getCustomHeaders(),
'Content-Type': 'application/json',
...(auth === 'bearer' && { Authorization: `Bearer ${this.apiKey}` }),
...(gzippedPayload !== null && { 'Content-Encoding': 'gzip' }),
},
body: gzippedPayload || serialized,
Expand Down Expand Up @@ -1703,11 +1710,23 @@ export abstract class PostHogCoreStateless {
}

async _sendLogsBatch(payload: OtlpLogsPayload): Promise<SendLogsBatchOutcome> {
return this._sendOtlpBatch({ path: 'logs', payload })
return this._sendOtlpBatch({ path: 'logs', auth: 'query-token', payload })
}

async _sendMetricsBatch(payload: OtlpMetricsPayload): Promise<SendMetricsBatchOutcome> {
return this._sendOtlpBatch({ path: 'metrics', payload })
return this._sendOtlpBatch({ path: 'metrics', auth: 'query-token', payload })
}

/**
* The `TracesHost._sendTracesBatch` implementation, so `PostHogTraces` can
* use any core-based SDK as its host.
*
* Authenticates with `Authorization: Bearer` rather than the `?token=` query
* parameter the logs and metrics senders use: it's the service's primary auth
* path, and server runtimes have no CORS preflight to avoid.
*/
async _sendTracesBatch(payload: OtlpTracesPayload): Promise<SendTracesBatchOutcome> {
return this._sendOtlpBatch({ path: 'traces', auth: 'bearer', payload })
}

private fetchWithRetry<T>(
Expand Down
Loading
Loading