Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
06270a8
docs(observability-react-native): add distributed tracing guide
abelonogov-ld Jun 24, 2026
894e208
docs(observability-react-native): add OTel baggage example to tracing…
abelonogov-ld Jun 24, 2026
351b72d
docs(observability-react-native): add Tracing tab to example app
abelonogov-ld Jun 24, 2026
1143993
chore(example): gitignore RN example iOS/Ruby lock files
abelonogov-ld Jun 24, 2026
3fc0099
fix(observability-android): report service.name and service.version a…
abelonogov-ld Jun 25, 2026
c6217bb
Merge branch 'fix/android-service-name' (service.name resource attrib…
abelonogov-ld Jun 25, 2026
9086597
chore(session-replay-rn): bump launchdarkly-android-client-sdk to 0.46.1
abelonogov-ld Jun 25, 2026
c907c45
RN work
abelonogov-ld Jun 25, 2026
2598138
Merge branch 'chore/bump-android-client-sdk' into docs/rn-distributed…
abelonogov-ld Jun 25, 2026
d2376ab
adopting
abelonogov-ld Jun 25, 2026
7407ddd
update Android dependencies
abelonogov-ld Jun 25, 2026
9564d68
feat(observability-android): support external session id
abelonogov-ld Jun 25, 2026
50d39bd
feat(observability-android): disable auto rotation for external sessi…
abelonogov-ld Jun 25, 2026
d61873c
chore(observability-android): drop unused activity instrumentation de…
abelonogov-ld Jun 25, 2026
ed34ca3
fix(observability-android): prime session manager with initial backgr…
abelonogov-ld Jun 25, 2026
b475547
LDObserve track
abelonogov-ld Jun 25, 2026
dc44a8e
Merge branch 'feat/android-external-session-id' into docs/rn-distribu…
abelonogov-ld Jun 25, 2026
1a92ad8
utils
abelonogov-ld Jun 25, 2026
cf197e9
Merge branch 'main' into docs/rn-distributed-tracing
abelonogov-ld Jun 25, 2026
898bd11
bump
abelonogov-ld Jun 25, 2026
8fa2015
docs(observability-react-native): add withSpan manual tracing example…
abelonogov-ld Jun 25, 2026
ad0d4b3
style: apply prettier formatting to RN example and shared test
abelonogov-ld Jun 25, 2026
3fa3851
docs(observability-react-native): make root-span recipe verify trace …
abelonogov-ld Jun 25, 2026
eebc347
Merge branch 'main' into docs/rn-distributed-tracing
abelonogov-ld Jun 25, 2026
a37d435
fix(example): keep nested-span demo parented across awaits via withSp…
abelonogov-ld Jun 25, 2026
e2fc3e2
docs(observability-react-native): sync tracing guide examples with th…
abelonogov-ld Jun 25, 2026
eef4346
docs(observability-react-native): inline demo URLs so tracing example…
abelonogov-ld Jun 25, 2026
2bb3f5d
fix(observability-shared): anchor tracingOrigins host patterns to the…
abelonogov-ld Jun 25, 2026
9d06f16
fix(observability-react-native): keep LD event key/value precedence o…
abelonogov-ld Jun 25, 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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ sdk/@launchdarkly/mobile-dotnet/**/*.csproj.user
sdk/@launchdarkly/mobile-dotnet/**/*.sln
sdk/@launchdarkly/mobile-dotnet/**/nupkgs/
sdk/@launchdarkly/mobile-dotnet/**/xcuserdata/

# React Native session-replay example: local iOS/Ruby tooling locks
# (regenerated per-machine by `pod install` / `bundle install`).
# NOTE: the monorepo yarn.lock is intentionally NOT ignored.
sdk/@launchdarkly/react-native-ld-session-replay/example/ios/Podfile.lock
sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.launchdarkly.sdk.android.LDClient
import com.launchdarkly.sdk.android.LDConfig
import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.common.Attributes
import kotlin.time.Duration.Companion.minutes

open class BaseApplication : Application() {

Expand All @@ -43,6 +44,7 @@ open class BaseApplication : Application() {
debug = true,
otlpEndpoint = BuildConfig.OTLP_ENDPOINT,
backendUrl = BuildConfig.BACKEND_URL,
sessionBackgroundTimeout = 3.minutes,
tracesApi = ObservabilityOptions.TracesApi.enabled(),
metricsApi = ObservabilityOptions.MetricsApi.enabled(),
instrumentations = ObservabilityOptions.Instrumentations(
Expand Down
28 changes: 28 additions & 0 deletions sdk/@launchdarkly/observability-react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ const client = new LDClient(
);
```

### Manual tracing

Use the `LDObserve` singleton to create spans by hand. `withSpan` runs your
callback inside a span and ends it automatically — even across `await`s, where
React Native only tracks the active context synchronously. Use `scope.child` to
parent nested spans off the captured context:

```typescript
import { LDObserve } from '@launchdarkly/observability-react-native';

await LDObserve.withSpan('LoadProducts', async (scope) => {
scope.span.setAttribute('source', 'api');

// `scope.child` parents off LoadProducts even after the await above.
const products = await scope.child('FetchFromApi', async (fetchScope) => {
const response = await fetch('https://api.example.com/products');
fetchScope.span.setAttribute('http.status_code', response.status);
return response.json();
});

scope.span.setAttribute('product_count', products.length);
});
```

## Guides

- [Tracing Guide](guides/tracing.md) — a cookbook of common tracing patterns (spans, nested operations, error handling, correlated logs, and end-to-end mobile-to-backend traces).

## About LaunchDarkly

- LaunchDarkly Observability provides a way to collect and send errors, logs, traces to LaunchDarkly. Correlate latency or exceptions with your releases to safely ship code.
Expand Down
733 changes: 733 additions & 0 deletions sdk/@launchdarkly/observability-react-native/guides/tracing.md

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions sdk/@launchdarkly/observability-react-native/src/api/Observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { Metric } from './Metric'
import { RequestContext } from './RequestContext'
import { SessionInfo } from '../client/SessionManager'
import { SpanScope, WithSpanOptions } from './SpanScope'

export interface Observe {
/**
Expand Down Expand Up @@ -64,6 +65,20 @@ export interface Observe {
*/
recordLog(message: any, level: string, attributes?: Attributes): void

/**
* Record a custom track event as a `track` span.
*
* Mirrors the iOS and Android `LDObserve.track(...)` API (and `LDClient.track`):
* emits a span named `track` carrying the event `key`, an optional numeric
* `value` for LaunchDarkly numeric custom metrics, and any `properties` as
* additional span attributes.
*
* @param key The key for the event.
* @param properties Optional data associated with the event; attached as span attributes.
* @param metricValue Optional numeric value used by LaunchDarkly experimentation for numeric custom metrics.
*/
track(key: string, properties?: Attributes, metricValue?: number): void

/**
* Parse headers to extract request context.
* @param headers The headers to parse
Expand Down Expand Up @@ -116,6 +131,30 @@ export interface Observe {
ctx?: Context,
): T

/**
* Start a span, run `fn` within it, and end the span automatically.
*
* This is an ergonomic wrapper over {@link startSpan} designed for React
* Native, where the active context is tracked only synchronously and is lost
* across each `await`. The {@link SpanScope} passed to `fn` exposes a
* {@link SpanScope.child} method that parents child spans off the captured
* context, so the hierarchy is preserved across `await`s and even under
* concurrent (`Promise.all`) work — without manually threading context.
*
* The span's status is set to `OK` on success, or `ERROR` (with the error
* recorded) if `fn` throws or returns a rejecting promise. If `fn` returns a
* promise, the span ends when it settles and the promise is returned.
*
* @param spanName The span name
* @param fn The callback to run within the span's scope
* @param options Optional span options, including an explicit `parent`
*/
withSpan<T>(
spanName: string,
fn: (scope: SpanScope) => T,
options?: WithSpanOptions,
): T

/**
* Get the context from a span.
* @param span The span to get the context from
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Context, Span as OtelSpan, SpanOptions } from '@opentelemetry/api'

/**
* Options accepted by {@link Observe.withSpan} and {@link SpanScope.child}.
*
* In addition to the standard OpenTelemetry {@link SpanOptions}, an explicit
* `parent` context can be supplied. This is the mechanism that makes spans
* nest correctly across `await`s in React Native, where the active context is
* tracked only synchronously (see the distributed tracing guide).
*/
export type WithSpanOptions = SpanOptions & {
/**
* The context to parent the new span under. When omitted the currently
* active context is used. {@link SpanScope.child} sets this automatically to
* the parent scope's context, so children nest correctly even after an
* `await`.
*/
parent?: Context
}

/**
* A handle to a span started with {@link Observe.withSpan}.
*
* A scope captures its own span context, so child spans created via
* {@link SpanScope.child} are parented correctly even across `await`
* boundaries — without manually threading context through your code.
*/
export interface SpanScope {
/** The underlying OpenTelemetry span. */
readonly span: OtelSpan

/**
* This span's context. Pass it anywhere an explicit parent {@link Context}
* is required.
*/
readonly ctx: Context

/**
* Start a child span parented to *this* scope, run `fn`, and end the span
* automatically. The span's status is set to `OK` on success, or `ERROR`
* (with the thrown error recorded) if `fn` throws or rejects.
*
* Because the parent is captured from this scope rather than read from the
* active context, the child nests correctly even when created after an
* `await`.
*
* @param name The child span name
* @param fn The callback to run within the child scope
* @param options Optional span options
*/
child<T>(
name: string,
fn: (scope: SpanScope) => T,
options?: WithSpanOptions,
): T

/**
* Run `fn` with *this* span active. Use this to parent auto-instrumented
* `fetch`/`XMLHttpRequest` spans that are started after an `await` (the
* point at which React Native loses the active context). Calls started in
* the synchronous portion of a {@link Observe.withSpan} callback are already
* parented automatically.
*
* @param fn The callback to run with this span active
*/
active<T>(fn: () => T): T
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './Options'
export * from './Metric'
export * from './RequestContext'
export type { SpanScope, WithSpanOptions } from './SpanScope'
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Gauge,
Histogram,
Span as OtelSpan,
SpanKind,
SpanOptions,
trace,
propagation,
Expand Down Expand Up @@ -59,6 +60,9 @@ export type InstrumentationManagerOptions = Required<ReactNativeOptions> & {
projectId: string
}

// Span name for custom track events, matching the iOS/Android SDKs (`track`).
const TRACK_SPAN_NAME = 'track'

Comment thread
cursor[bot] marked this conversation as resolved.
export class InstrumentationManager {
private traceProvider?: WebTracerProvider
private loggerProvider?: LoggerProvider
Expand Down Expand Up @@ -393,6 +397,36 @@ export class InstrumentationManager {
}
}

/**
* Single emitter for `track` spans, mirroring the iOS/Android `track` API.
* Emits a span named `track` carrying the event `key`, an optional numeric
* `value`, and any caller `properties` as attributes.
*/
public track(
key: string,
properties?: Attributes,
metricValue?: number,
): void {
try {
const sessionId = this.sessionManager?.getSessionInfo().sessionId
const attributes: Attributes = {
...(properties ?? {}),
key,
...(metricValue !== undefined ? { value: metricValue } : {}),
...(sessionId ? { ['highlight.session_id']: sessionId } : {}),
}

this.getTracer()
.startSpan(TRACK_SPAN_NAME, {
kind: SpanKind.CLIENT,
attributes,
})
.end()
} catch (e) {
console.error('Failed to record track event:', e)
}
}

Comment thread
cursor[bot] marked this conversation as resolved.
public runWithHeaders(
name: string,
headers: Record<string, string>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ export class ObservabilityClient {
this.instrumentationManager.recordLog(message, level, attributes)
}

public track(
key: string,
properties?: Attributes,
metricValue?: number,
): void {
if (this.options.disableTraces) return
this.instrumentationManager.track(key, properties, metricValue)
}

public parseHeaders(headers: Record<string, string>): RequestContext {
const sessionInfo = this.sessionManager.getSessionInfo()
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ export const FEATURE_FLAG_CONTEXT_ID_ATTR = `${FEATURE_FLAG_SCOPE}.context.id`
export const FEATURE_FLAG_ENV_ATTR = `${FEATURE_FLAG_SCOPE}.environment.id`

export const LD_SCOPE = 'launchdarkly'
// Span name for `track` events recorded via the LaunchDarkly client's
// afterTrack hook. Uses the simple "track" name to match the mobile SDKs
// (iOS / Android / Flutter).
export const LD_TRACK_SPAN_NAME = 'track'
export const FEATURE_FLAG_APP_ID_ATTR = `${LD_SCOPE}.application.id`
export const FEATURE_FLAG_APP_VERSION_ATTR = `${LD_SCOPE}.application.version`
export const LD_IDENTIFY_RESULT_STATUS = `${LD_SCOPE}.identify.result.status`
// Universal, cross-SDK marker for telemetry produced internally by the
// LaunchDarkly SDK itself (e.g. flag-evaluation spans), so it can be filtered or
// hidden regardless of which SDK/instrumentation-scope emitted it.
export const LD_INTERNAL_ATTR = `${LD_SCOPE}.internal`

export const FEATURE_FLAG_REASON_ATTRS: {
[key in keyof LDEvaluationReason]: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
getCanonicalKey,
getContextKeys,
LD_IDENTIFY_RESULT_STATUS,
LD_INTERNAL_ATTR,
LD_TRACK_SPAN_NAME,
} from '../constants/featureFlags'
import type { LDEvaluationReason } from '@launchdarkly/js-sdk-common'
import {
Expand All @@ -35,6 +37,7 @@ import {
IdentifySeriesContext,
EvaluationSeriesContext,
EvaluationSeriesData,
TrackSeriesContext,
} from '@launchdarkly/react-native-client-sdk'

class TracingHook implements Hook {
Expand Down Expand Up @@ -126,6 +129,9 @@ class TracingHook implements Hook {
[FEATURE_FLAG_KEY_ATTR]: hookContext.flagKey,
[FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly',
[FEATURE_FLAG_VALUE_ATTR]: JSON.stringify(detail.value),
// Mark this as SDK-internal telemetry so it can be filtered
// out universally (independent of instrumentation scope).
[LD_INTERNAL_ATTR]: true,
})

span.setStatus({ code: 1 })
Expand All @@ -146,6 +152,41 @@ class TracingHook implements Hook {

return data
}

afterTrack(hookContext: TrackSeriesContext): void {
try {
const trackAttributes: Attributes = {
...this.metaAttributes,
...(hookContext.context
? getContextKeys(hookContext.context)
: {}),
// Spread user-supplied track data first so the LaunchDarkly event
// `key` and metric `value` set below always win over any
// same-named properties in the payload. Non-primitive members are
// ignored by OpenTelemetry.
...(typeof hookContext.data === 'object' &&
hookContext.data !== null
? (hookContext.data as Attributes)
: {}),
key: hookContext.key,
...(hookContext.metricValue !== undefined &&
hookContext.metricValue !== null
? { value: hookContext.metricValue }
: {}),
}
Comment thread
cursor[bot] marked this conversation as resolved.

_LDObserve.startActiveSpan(LD_TRACK_SPAN_NAME, (span) => {
span.setAttributes(trackAttributes)
span.setStatus({ code: 1 })
Comment thread
cursor[bot] marked this conversation as resolved.
span.end()
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
abelonogov-ld marked this conversation as resolved.
})
Comment thread
abelonogov-ld marked this conversation as resolved.
} catch (error) {
_LDObserve.recordError(error as Error, {
'track.key': hookContext.key,
'error.context': 'track_tracing',
})
}
}
}

export class Observability implements LDPlugin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import { ObservabilityClient } from '../client/ObservabilityClient'
import { Metric } from '../api/Metric'
import { RequestContext } from '../api/RequestContext'
import { Observe } from '../api/Observe'
import { SpanScope, WithSpanOptions } from '../api/SpanScope'
import { BufferedClass } from './BufferedClass'
import { noOpSpan } from '../utils/NoOpSpan'
import { NOOP_SPAN_OPS, runInSpan, SpanOps } from './withSpan'

class LDObserveClass
extends BufferedClass<ObservabilityClient>
Expand Down Expand Up @@ -56,6 +58,10 @@ class LDObserveClass
return this._bufferCall('recordLog', [message, level, attributes])
}

track(key: string, properties?: Attributes, metricValue?: number): void {
return this._bufferCall('track', [key, properties, metricValue])
}

parseHeaders(headers: Record<string, string>): RequestContext {
const response = this._bufferCall('parseHeaders', [headers])
return this._isLoaded
Expand Down Expand Up @@ -127,6 +133,18 @@ class LDObserveClass
: fn(noOpSpan.setAttribute('method', 'startActiveSpan'))
}

withSpan<T>(
spanName: string,
fn: (scope: SpanScope) => T,
options?: WithSpanOptions,
): T {
// When loaded, `this` provides real startSpan/recordError that execute
// immediately (no buffering). Before init we use no-op span ops so the
// callback still runs without enqueueing stray spans.
const ops: SpanOps = this._isLoaded ? this : NOOP_SPAN_OPS
return runInSpan(ops, spanName, fn, options)
}

getContextFromSpan(span: OtelSpan): Context {
const response = this._bufferCall('getContextFromSpan', [span])
return this._isLoaded ? response : context.active()
Expand Down
Loading
Loading