From 93279f6e5588862901941dc043302526e9d80004 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 3 Sep 2026 15:57:56 -0400 Subject: [PATCH] refactor(traces): keep the traces plumbing off the public API Moves resolveTracesConfig into core beside resolveMetricsConfig and assignUserAttributes into utils, so packages/node no longer reaches for either, and replaces the duplicated activation rule with runWithActiveSpan. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01L8eFZB35NExUZyq5hGgh43 --- packages/core/src/index.ts | 4 +-- .../src/traces/config.spec.ts} | 2 +- .../src/traces/config.ts} | 5 +-- packages/core/src/traces/index.ts | 11 +++--- packages/core/src/traces/sanitize.ts | 36 ------------------- packages/core/src/traces/span.ts | 17 +++++++-- packages/core/src/utils/json-utils.ts | 33 +++++++++++++++++ packages/core/src/utils/otlp-resource.ts | 2 +- packages/node/src/client.ts | 7 ++-- 9 files changed, 62 insertions(+), 55 deletions(-) rename packages/{node/src/__tests__/traces-defaults.spec.ts => core/src/traces/config.spec.ts} (99%) rename packages/{node/src/traces-defaults.ts => core/src/traces/config.ts} (95%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8b93ec090..8a3aad8015 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,7 +45,6 @@ export { } from './logs/logs-utils' export { toOtlpAnyValue, toOtlpKeyValueList } from './utils/otlp-any-value' export { osResourceAttributes } from './utils/otlp-resource' -export { assignUserAttributes } from './traces/sanitize' export { PostHogLogs } from './logs' export type { BeforeSendLogFn, @@ -90,7 +89,8 @@ export type { } from './metrics/types' export { PostHogTraces } from './traces' export { SyncSpanContextManager } from './traces/context' -export { NOOP_SPAN, inertSpan } from './traces/span' +export { inertSpan, runWithActiveSpan } from './traces/span' +export { resolveTracesConfig } from './traces/config' export type { ResolvedTracesConfig, SpanContextManager, TraceSdkContext } from './traces/types' // Same barrel convention as logs and metrics for the user-facing tracing types. export type { diff --git a/packages/node/src/__tests__/traces-defaults.spec.ts b/packages/core/src/traces/config.spec.ts similarity index 99% rename from packages/node/src/__tests__/traces-defaults.spec.ts rename to packages/core/src/traces/config.spec.ts index b767799afa..902e5f35f5 100644 --- a/packages/node/src/__tests__/traces-defaults.spec.ts +++ b/packages/core/src/traces/config.spec.ts @@ -1,4 +1,4 @@ -import { resolveTracesConfig } from '../traces-defaults' +import { resolveTracesConfig } from './config' describe('resolveTracesConfig', () => { it('applies the documented defaults', () => { diff --git a/packages/node/src/traces-defaults.ts b/packages/core/src/traces/config.ts similarity index 95% rename from packages/node/src/traces-defaults.ts rename to packages/core/src/traces/config.ts index f4982505bc..4cec80a594 100644 --- a/packages/node/src/traces-defaults.ts +++ b/packages/core/src/traces/config.ts @@ -1,5 +1,6 @@ -import { assignUserAttributes } from '@posthog/core' -import type { ResolvedTracesConfig, TracesConfig } from '@posthog/core' +import { assignUserAttributes } from '../utils/json-utils' +import type { ResolvedTracesConfig } from './types' +import type { TracesConfig } from '@posthog/types' // OpenTelemetry's BatchSpanProcessor defaults, which sit comfortably under the // server's 2 MB body cap. diff --git a/packages/core/src/traces/index.ts b/packages/core/src/traces/index.ts index 7fd69b87db..89b42f1a14 100644 --- a/packages/core/src/traces/index.ts +++ b/packages/core/src/traces/index.ts @@ -8,10 +8,11 @@ import type { TraceSdkContext, TracesHost, } from './types' -import { NOOP_SPAN, PostHogSpan, describeError, inertSpan, monotonicNow } from './span' +import { NOOP_SPAN, PostHogSpan, describeError, inertSpan, monotonicNow, runWithActiveSpan } from './span' import { newSpanId, newTraceId } from './ids' import { parseTraceparent, sanitizeTracestate } from './traceparent' -import { assignUserAttributes, resolveStartTime, sanitizeName } from './sanitize' +import { resolveStartTime, sanitizeName } from './sanitize' +import { assignUserAttributes } from '../utils/json-utils' import { buildOtlpSpan, buildOtlpTracesPayload, buildTracesResourceAttributes } from './otlp' import { isPromise, safeSetTimeout } from '../utils' @@ -180,11 +181,7 @@ export class PostHogTraces { const span = this.startSpan(name, options) try { - // The shared no-op is never activated, so `getActiveSpan()` inside the - // callback reads null — callbacks should use the handle they're given. A - // pass-through handle is activated, so `getActiveSpan()?.traceparent()` - // still propagates an inbound trace through a service with tracing off. - const result = span === NOOP_SPAN ? fn(span) : this._contextManager.with(span, () => fn(span)) + const result = runWithActiveSpan(this._contextManager, span, fn) if (isPromise(result)) { return result.then( diff --git a/packages/core/src/traces/sanitize.ts b/packages/core/src/traces/sanitize.ts index 47257deba7..bd31398941 100644 --- a/packages/core/src/traces/sanitize.ts +++ b/packages/core/src/traces/sanitize.ts @@ -6,7 +6,6 @@ import type { Logger } from '../types' import type { SpanAttributes, SpanTimeInput } from '@posthog/types' -import { UNSERIALIZABLE_VALUE } from '../utils/json-utils' const FALLBACK_SPAN_NAME = 'unknown' @@ -108,38 +107,3 @@ export function resolveSuppliedTime( } return supplied } - -/** - * Copies caller-supplied attributes onto `target`, own enumerable keys only. - * - * Read key by key rather than spread: a getter over a disposed resource or a - * revoked proxy throws on the read itself, before the encoder's guards see it. - * - * @internal Exposed for cross-package use within this SDK; not part of the stable public API. - */ -export function assignUserAttributes>( - target: T, - source: Record | undefined -): T { - if (!source) { - return target - } - let keys: string[] = [] - try { - keys = Object.keys(source) - } catch { - keys = [] - } - for (const key of keys) { - let value: unknown - try { - value = source[key] - } catch { - value = UNSERIALIZABLE_VALUE - } - // defineProperty, not assignment: `attributes['__proto__'] = v` hits the - // prototype setter and the attribute vanishes. - Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) - } - return target -} diff --git a/packages/core/src/traces/span.ts b/packages/core/src/traces/span.ts index f8564ae179..6336c00cbb 100644 --- a/packages/core/src/traces/span.ts +++ b/packages/core/src/traces/span.ts @@ -1,8 +1,9 @@ import type { Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatusCode, SpanTimeInput } from '@posthog/types' import type { Logger } from '../types' -import type { SpanEventRecord, SpanRecord } from './types' +import type { SpanContextManager, SpanEventRecord, SpanRecord } from './types' import { formatTraceparent, normalizeTraceparent, sanitizeTracestate, TRACE_FLAGS_SAMPLED } from './traceparent' -import { assignUserAttributes, clampEndTime, resolveSuppliedTime, sanitizeName } from './sanitize' +import { clampEndTime, resolveSuppliedTime, sanitizeName } from './sanitize' +import { assignUserAttributes } from '../utils/json-utils' import { isError } from '../utils' /** @@ -275,6 +276,18 @@ export function inertSpan(options?: { parent?: unknown; tracestate?: unknown }): return new PassThroughSpan(traceparent, sanitizeTracestate(options?.tracestate)) } +/** + * Runs `fn` with `span` active, which every scoped helper does the same way. + * + * The shared no-op is never activated, so `getActiveSpan()` inside the callback + * reads null — callbacks should use the handle they're given. A pass-through + * handle is activated, so `getActiveSpan()?.traceparent()` still propagates an + * inbound trace through a service with tracing off. + */ +export function runWithActiveSpan(contextManager: SpanContextManager, span: Span, fn: (span: Span) => T): T { + return span === NOOP_SPAN ? fn(span) : contextManager.with(span, () => fn(span)) +} + /** * Extracts the OTel `exception.type` / `exception.message` pair from whatever was * thrown. Anything can be thrown in JS, so non-Errors are described by type. diff --git a/packages/core/src/utils/json-utils.ts b/packages/core/src/utils/json-utils.ts index 95007246d8..bf92ecc80f 100644 --- a/packages/core/src/utils/json-utils.ts +++ b/packages/core/src/utils/json-utils.ts @@ -153,3 +153,36 @@ export function toJsonSafeValue(value: unknown): unknown { return convert(value, 0) } + +/** + * Copies caller-supplied attributes onto `target`, own enumerable keys only. + * + * Read key by key rather than spread: a getter over a disposed resource or a + * revoked proxy throws on the read itself, before the encoder's guards see it. + */ +export function assignUserAttributes>( + target: T, + source: Record | undefined +): T { + if (!source) { + return target + } + let keys: string[] = [] + try { + keys = Object.keys(source) + } catch { + keys = [] + } + for (const key of keys) { + let value: unknown + try { + value = source[key] + } catch { + value = UNSERIALIZABLE_VALUE + } + // defineProperty, not assignment: `attributes['__proto__'] = v` hits the + // prototype setter and the attribute vanishes. + Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true }) + } + return target +} diff --git a/packages/core/src/utils/otlp-resource.ts b/packages/core/src/utils/otlp-resource.ts index 09ac1d9090..50963e8488 100644 --- a/packages/core/src/utils/otlp-resource.ts +++ b/packages/core/src/utils/otlp-resource.ts @@ -1,4 +1,4 @@ -import { assignUserAttributes } from '../traces/sanitize' +import { assignUserAttributes } from './json-utils' /** * Shape the logs, metrics and traces resolved configs share for resource diff --git a/packages/node/src/client.ts b/packages/node/src/client.ts index 08cb08b189..adf99bee1a 100644 --- a/packages/node/src/client.ts +++ b/packages/node/src/client.ts @@ -18,10 +18,11 @@ import { PostHogMetrics, PostHogPersistedProperty, PostHogTraces, - NOOP_SPAN, inertSpan, Properties, resolveMetricsConfig, + resolveTracesConfig, + runWithActiveSpan, RetriableOptions, raceWithTimeout, safeSetTimeout, @@ -29,7 +30,6 @@ import { uuidv7, } from '@posthog/core' import type { Metrics, Span, SpanContextManager, StartSpanOptions, TraceSdkContext } from '@posthog/core' -import { resolveTracesConfig } from './traces-defaults' import { AllFlagsOptions, EventMessage, @@ -747,8 +747,7 @@ export abstract class PostHogBackendClient extends PostHogCoreStateless implemen // Tracing off: still run the callback exactly once, with an inert handle. // A handle carrying an inbound `parent` is activated, so `getActiveSpan()` // inside the callback can propagate the trace onward. - const span = inertSpan(options) - return span === NOOP_SPAN ? fn(span) : this._spanContextManager.with(span, () => fn(span)) + return runWithActiveSpan(this._spanContextManager, inertSpan(options), fn) } return options ? pipeline.withSpan(name, options, fn) : pipeline.withSpan(name, fn) }