Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/sour-trees-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livestore/utils': patch
---

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.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@ See the [S2 sync provider docs](https://dev.docs.livestore.dev/reference/syncing
#### Development Tooling

- **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)).
- **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)).
- **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)).
- **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)).
- **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)).
Expand Down
4 changes: 2 additions & 2 deletions context/02-system/06-observability/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ this node owns tracing/telemetry semantics.
traces.
- **LS.SYS.OBS-R02 No-op default:** Without an app-provided tracer,
instrumentation degrades to a built-in no-op tracer: no exporter, no
network, bounded per-span overhead (today: one object allocation plus
start/end timestamps — a stricter zero-allocation budget is open, see
network, bounded per-span overhead (today: one object allocation and a
shared invalid OpenTelemetry span context — a stricter zero-allocation budget is open, see
LS.SYS.OBS-DQ3). `refines: LS-R14`
- **LS.SYS.OBS-R03 Injectable tracer:** Apps provide their tracer/exporter;
LiveStore never configures a global exporter on its own.
Expand Down
7 changes: 3 additions & 4 deletions context/02-system/06-observability/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ Draft.
(LS.SYS.OBS-R03).
- `utils/src/NoopTracer.ts` is the default when no tracer is provided
(LS.SYS.OBS-R02). It is cheap but not free: each span allocates a span
object and reads `performance.now()` at start and end (plus two `cuid()`
calls if `spanContext()` is consulted). A zero-allocation no-op path on
object and returns OpenTelemetry's shared invalid span context. A zero-allocation no-op path on
the synchronous read path is an aspiration, not current behavior
(LS.SYS.OBS-DQ3).
- Dev-tracing recipe: `@livestore/utils-dev` `OtelLiveHttp` wires OTLP HTTP
Expand Down Expand Up @@ -76,8 +75,8 @@ linkage today. Convergence is an open direction.
- **LS.SYS.OBS-DQ2 Metrics contract.** No metrics are emitted today; whether
LiveStore should expose counters/histograms (commit rate, rebase count,
query latency) is undesigned.
- **LS.SYS.OBS-DQ3 No-op overhead budget.** The NoopTracer allocates and
reads clocks per span on hot paths (issue #1420); whether a
- **LS.SYS.OBS-DQ3 No-op overhead budget.** The NoopTracer allocates one
object per span on hot paths (issue #1420); whether a
zero-allocation budget should be contracted (and how to verify it) is
open. Kept deliberately open 2026-07-16 (interview).

Expand Down
20 changes: 20 additions & 0 deletions packages/@livestore/utils/src/NoopTracer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { INVALID_SPAN_CONTEXT, isSpanContextValid, ROOT_CONTEXT } from '@opentelemetry/api'
import { describe, expect, it } from 'vitest'

import { makeNoopSpan, makeNoopTracer } from './NoopTracer.ts'

describe('NoopTracer', () => {
it('returns the canonical invalid OpenTelemetry span context', () => {
const spanContext = makeNoopSpan().spanContext()

expect(spanContext).toBe(INVALID_SPAN_CONTEXT)
expect(isSpanContextValid(spanContext)).toBe(false)
})

it('runs active-span callbacks for every tracer overload', () => {
const tracer = makeNoopTracer()
expect(tracer.startActiveSpan('two arguments', () => 'two')).toBe('two')
expect(tracer.startActiveSpan('three arguments', {}, () => 'three')).toBe('three')
expect(tracer.startActiveSpan('four arguments', {}, ROOT_CONTEXT, () => 'four')).toBe('four')
})
})
45 changes: 17 additions & 28 deletions packages/@livestore/utils/src/NoopTracer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type * as otel from '@opentelemetry/api'

import { cuid } from '@livestore/utils/cuid'
import { INVALID_SPAN_CONTEXT, type Context, type Span, type SpanOptions, type Tracer } from '@opentelemetry/api'

export const makeNoopSpan = () => {
const spanImpl = {
Expand All @@ -12,46 +10,37 @@ export const makeNoopSpan = () => {
updateName: () => null,
recordException: () => null,
end: () => null,
spanContext: () => {
return {
traceId: `livestore-noop-trace-id${cuid()}`,
spanId: `livestore-noop-span-id${cuid()}`,
}
},
spanContext: () => INVALID_SPAN_CONTEXT,
}

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

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

export class NoopTracer {
startSpan = () => makeNoopSpan()

startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(name: string, fn: F): ReturnType<F>
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
name: string,
opts: otel.SpanOptions,
fn: F,
): ReturnType<F>
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, fn: F): ReturnType<F>
startActiveSpan<F extends (span: Span) => ReturnType<F>>(name: string, opts: SpanOptions, fn: F): ReturnType<F>
startActiveSpan<F extends (span: Span) => ReturnType<F>>(
name: string,
opts: otel.SpanOptions,
ctx: otel.Context,
opts: SpanOptions,
ctx: Context,
fn: F,
): ReturnType<F>
startActiveSpan<F extends (span: otel.Span) => ReturnType<F>>(
startActiveSpan<F extends (span: Span) => ReturnType<F>>(
_name: string,
arg2?: F | otel.SpanOptions,
arg3?: F | otel.Context,
arg2?: F | SpanOptions,
arg3?: F | Context,
arg4?: F,
): ReturnType<F> | undefined {
let _opts: otel.SpanOptions | undefined
let _ctx: otel.Context | undefined
let _opts: SpanOptions | undefined
let _ctx: Context | undefined
let fn: F

if (arguments.length < 2) {
Expand All @@ -61,14 +50,14 @@ export class NoopTracer {
fn = arg2 as F
} else if (arguments.length === 3) {
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 3 args, arg2 is SpanOptions
_opts = arg2 as otel.SpanOptions | undefined
_opts = arg2 as SpanOptions | undefined
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 3 args, arg3 is the callback
fn = arg3 as F
} else {
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg2 is SpanOptions
_opts = arg2 as otel.SpanOptions | undefined
_opts = arg2 as SpanOptions | undefined
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg3 is Context
_ctx = arg3 as otel.Context | undefined
_ctx = arg3 as Context | undefined
// oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- arguments-based overload dispatch: with 4 args, arg4 is the callback
fn = arg4 as F
}
Expand Down
Loading