Skip to content

Commit c61228a

Browse files
fix(utils): use canonical no-op span context for Expo compatibility (#1452)
Uses OTel's canonical INVALID_SPAN_CONTEXT for the no-op tracer (removes the custom ID generator + self/navigator hardening), fixing Metro/Expo bundling of @livestore/utils. Ships with its regression test (NoopTracer.test.ts).
1 parent 2668dfa commit c61228a

6 files changed

Lines changed: 48 additions & 34 deletions

File tree

.changeset/sour-trees-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livestore/utils': patch
3+
---
4+
5+
Use OpenTelemetry's canonical invalid span context for no-op spans, avoiding a platform-specific ID generator that Metro could not resolve through a package self-import.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ See the [S2 sync provider docs](https://dev.docs.livestore.dev/reference/syncing
513513
#### Development Tooling
514514
515515
- **Effect v4 dependency cohort:** Updated the repository-wide Effect v4 dependency family to beta.99 and migrated graph access to the public API while preserving degree-local history traversal ([#1446](https://github.com/livestorejs/livestore/issues/1446)).
516+
- **Expo source-linked tracing:** No-op spans now use OpenTelemetry's canonical invalid span context, so Metro can bundle workspace source without a platform-specific ID generator ([#1450](https://github.com/livestorejs/livestore/issues/1450)).
516517
- **Strict peer dep composition:** Added `@effect/vitest` to `utilsEffectPeerDeps` and `@livestore/peer-deps`, and deduplicated the peer-deps package to derive its dependency list from the canonical `utilsEffectPeerDeps` source ([#1107](https://github.com/livestorejs/livestore/issues/1107)).
517518
- **Hosted example link validation:** Maintainers now have a shared deployment metadata source and `mono examples validate-links` check so docs and example deployments can catch stale first-party demo URLs before publishing ([#1244](https://github.com/livestorejs/livestore/issues/1244)).
518519
- **Chrome DevTools extension assets restored:** Restored `qrcode-generator` 2.0.4 in `@livestore/utils` and included the Chrome DevTools extension assets in the release artifact flow so the published DevTools package contains the Chrome extension build alongside the Vite plugin ([#1215](https://github.com/livestorejs/livestore/pull/1215)).

context/02-system/06-observability/requirements.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ this node owns tracing/telemetry semantics.
1818
traces.
1919
- **LS.SYS.OBS-R02 No-op default:** Without an app-provided tracer,
2020
instrumentation degrades to a built-in no-op tracer: no exporter, no
21-
network, bounded per-span overhead (today: one object allocation plus
22-
start/end timestamps — a stricter zero-allocation budget is open, see
21+
network, bounded per-span overhead (today: one object allocation and a
22+
shared invalid OpenTelemetry span context — a stricter zero-allocation budget is open, see
2323
LS.SYS.OBS-DQ3). `refines: LS-R14`
2424
- **LS.SYS.OBS-R03 Injectable tracer:** Apps provide their tracer/exporter;
2525
LiveStore never configures a global exporter on its own.

context/02-system/06-observability/spec.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ Draft.
1818
(LS.SYS.OBS-R03).
1919
- `utils/src/NoopTracer.ts` is the default when no tracer is provided
2020
(LS.SYS.OBS-R02). It is cheap but not free: each span allocates a span
21-
object and reads `performance.now()` at start and end (plus two `cuid()`
22-
calls if `spanContext()` is consulted). A zero-allocation no-op path on
21+
object and returns OpenTelemetry's shared invalid span context. A zero-allocation no-op path on
2322
the synchronous read path is an aspiration, not current behavior
2423
(LS.SYS.OBS-DQ3).
2524
- Dev-tracing recipe: `@livestore/utils-dev` `OtelLiveHttp` wires OTLP HTTP
@@ -76,8 +75,8 @@ linkage today. Convergence is an open direction.
7675
- **LS.SYS.OBS-DQ2 Metrics contract.** No metrics are emitted today; whether
7776
LiveStore should expose counters/histograms (commit rate, rebase count,
7877
query latency) is undesigned.
79-
- **LS.SYS.OBS-DQ3 No-op overhead budget.** The NoopTracer allocates and
80-
reads clocks per span on hot paths (issue #1420); whether a
78+
- **LS.SYS.OBS-DQ3 No-op overhead budget.** The NoopTracer allocates one
79+
object per span on hot paths (issue #1420); whether a
8180
zero-allocation budget should be contracted (and how to verify it) is
8281
open. Kept deliberately open 2026-07-16 (interview).
8382

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { INVALID_SPAN_CONTEXT, isSpanContextValid, ROOT_CONTEXT } from '@opentelemetry/api'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { makeNoopSpan, makeNoopTracer } from './NoopTracer.ts'
5+
6+
describe('NoopTracer', () => {
7+
it('returns the canonical invalid OpenTelemetry span context', () => {
8+
const spanContext = makeNoopSpan().spanContext()
9+
10+
expect(spanContext).toBe(INVALID_SPAN_CONTEXT)
11+
expect(isSpanContextValid(spanContext)).toBe(false)
12+
})
13+
14+
it('runs active-span callbacks for every tracer overload', () => {
15+
const tracer = makeNoopTracer()
16+
expect(tracer.startActiveSpan('two arguments', () => 'two')).toBe('two')
17+
expect(tracer.startActiveSpan('three arguments', {}, () => 'three')).toBe('three')
18+
expect(tracer.startActiveSpan('four arguments', {}, ROOT_CONTEXT, () => 'four')).toBe('four')
19+
})
20+
})

packages/@livestore/utils/src/NoopTracer.ts

Lines changed: 17 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import type * as otel from '@opentelemetry/api'
2-
3-
import { cuid } from '@livestore/utils/cuid'
1+
import { INVALID_SPAN_CONTEXT, type Context, type Span, type SpanOptions, type Tracer } from '@opentelemetry/api'
42

53
export const makeNoopSpan = () => {
64
const spanImpl = {
@@ -12,46 +10,37 @@ export const makeNoopSpan = () => {
1210
updateName: () => null,
1311
recordException: () => null,
1412
end: () => null,
15-
spanContext: () => {
16-
return {
17-
traceId: `livestore-noop-trace-id${cuid()}`,
18-
spanId: `livestore-noop-span-id${cuid()}`,
19-
}
20-
},
13+
spanContext: () => INVALID_SPAN_CONTEXT,
2114
}
2215

2316
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- noop otel.Span implementation; only implements the subset needed by LiveStore
24-
return spanImpl as unknown as otel.Span
17+
return spanImpl as unknown as Span
2518
}
2619

2720
export const makeNoopTracer = () => {
2821
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- noop otel.Tracer implementation; only implements the subset needed by LiveStore
29-
return new NoopTracer() as unknown as otel.Tracer
22+
return new NoopTracer() as unknown as Tracer
3023
}
3124

3225
export class NoopTracer {
3326
startSpan = () => makeNoopSpan()
3427

35-
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(name: string, fn: F): ReturnType<F>
36-
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
37-
name: string,
38-
opts: otel.SpanOptions,
39-
fn: F,
40-
): ReturnType<F>
41-
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
28+
startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, fn: F): ReturnType<F>
29+
startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, opts: SpanOptions, fn: F): ReturnType<F>
30+
startActiveSpan<F extends (span: Span) => ReturnType<F>>(
4231
name: string,
43-
opts: otel.SpanOptions,
44-
ctx: otel.Context,
32+
opts: SpanOptions,
33+
ctx: Context,
4534
fn: F,
4635
): ReturnType<F>
47-
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
36+
startActiveSpan<F extends (span: Span) => ReturnType<F>>(
4837
_name: string,
49-
arg2?: F | otel.SpanOptions,
50-
arg3?: F | otel.Context,
38+
arg2?: F | SpanOptions,
39+
arg3?: F | Context,
5140
arg4?: F,
5241
): ReturnType<F> | undefined {
53-
let _opts: otel.SpanOptions | undefined
54-
let _ctx: otel.Context | undefined
42+
let _opts: SpanOptions | undefined
43+
let _ctx: Context | undefined
5544
let fn: F
5645

5746
if (arguments.length < 2) {
@@ -61,14 +50,14 @@ export class NoopTracer {
6150
fn = arg2 as F
6251
} else if (arguments.length === 3) {
6352
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 3 args, arg2 is SpanOptions
64-
_opts = arg2 as otel.SpanOptions | undefined
53+
_opts = arg2 as SpanOptions | undefined
6554
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 3 args, arg3 is the callback
6655
fn = arg3 as F
6756
} else {
6857
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg2 is SpanOptions
69-
_opts = arg2 as otel.SpanOptions | undefined
58+
_opts = arg2 as SpanOptions | undefined
7059
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg3 is Context
71-
_ctx = arg3 as otel.Context | undefined
60+
_ctx = arg3 as Context | undefined
7261
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg4 is the callback
7362
fn = arg4 as F
7463
}

0 commit comments

Comments
 (0)