Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
2f643d2
feat(observability-react-native): accept plain nested dictionaries in…
abelonogov-ld Jun 25, 2026
f9cce37
Merge branch 'main' into feat/rn-track-plain-properties
abelonogov-ld Jun 26, 2026
fdef0a4
add buttons
abelonogov-ld Jun 26, 2026
efb4e49
fix(observability-react-native): polyfill URL.origin for React Native
abelonogov-ld Jun 26, 2026
0efd9e6
style: apply prettier formatting
abelonogov-ld Jun 26, 2026
1ccca15
Merge branch 'main' into feat/rn-track-plain-properties
abelonogov-ld Jun 26, 2026
4b05738
chore(session-replay-react-native): reconcile 0.13.0 release
abelonogov-ld Jun 26, 2026
c88ac16
test(session-replay-react-native): mock react-native-url-polyfill in …
abelonogov-ld Jun 26, 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
3 changes: 2 additions & 1 deletion sdk/@launchdarkly/observability-react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-trace-base": "2.0.1",
"@opentelemetry/sdk-trace-web": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.35.0"
"@opentelemetry/semantic-conventions": "^1.35.0",
"react-native-url-polyfill": "^3.0.0"
},
"peerDependencies": {
"@launchdarkly/react-native-client-sdk": "^10.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Metric } from './Metric'
import { RequestContext } from './RequestContext'
import { SessionInfo } from '../client/SessionManager'
import { SpanScope, WithSpanOptions } from './SpanScope'
import { TrackProperties } from './TrackProperties'

export interface Observe {
/**
Expand Down Expand Up @@ -73,11 +74,17 @@ export interface Observe {
* `value` for LaunchDarkly numeric custom metrics, and any `properties` as
* additional span attributes.
*
* `properties` is a plain dictionary (like the native `[String: Any]` /
* `Map<String, Any?>` surfaces): nested objects are flattened with
* dot-separated keys (e.g. `user.id`), arrays of objects with indexed dotted
* keys (e.g. `products.0.price`), and homogeneous scalar arrays become array
* attributes. `null` / `undefined` values are skipped.
*
* @param key The key for the event.
* @param properties Optional data associated with the event; attached as span attributes.
* @param properties Optional data associated with the event; flattened and 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
track(key: string, properties?: TrackProperties, metricValue?: number): void

/**
* Parse headers to extract request context.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* A plain, loosely-typed property value accepted by {@link Observe.track}.
*
* This mirrors the `[String: Any]` (iOS) / `Map<String, Any?>` (Android) `track`
* surface so callers can pass ordinary dictionaries — including nested objects
* and arrays — without first reshaping them into flat OpenTelemetry attributes.
* The SDK flattens the structure into attributes before recording the span.
*/
export type TrackPropertyValue =
| string
| number
| boolean
| null
| undefined
| TrackPropertyValue[]
| { [key: string]: TrackPropertyValue }

/**
* A plain dictionary of {@link Observe.track} properties.
*/
export type TrackProperties = { [key: string]: TrackPropertyValue }
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './Options'
export * from './Metric'
export * from './RequestContext'
export type { SpanScope, WithSpanOptions } from './SpanScope'
export type { TrackProperties, TrackPropertyValue } from './TrackProperties'
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
XHRHook,
} from '../listeners/network-listener/network-listener'
import { Metric } from '../api/Metric'
import { TrackProperties } from '../api/TrackProperties'
import { flattenTrackProperties } from '../utils/trackAttributes'
import { SessionManager } from './SessionManager'
import {
CustomSampler,
Expand Down Expand Up @@ -404,13 +406,13 @@ export class InstrumentationManager {
*/
public track(
key: string,
properties?: Attributes,
properties?: TrackProperties,
metricValue?: number,
): void {
try {
const sessionId = this.sessionManager?.getSessionInfo().sessionId
const attributes: Attributes = {
...(properties ?? {}),
...flattenTrackProperties(properties),
key,
...(metricValue !== undefined ? { value: metricValue } : {}),
...(sessionId ? { ['highlight.session_id']: sessionId } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ReactNativeOptions } from '../api/Options'
import { DEFAULT_URL_BLOCKLIST } from '../listeners/network-listener/utils/network-sanitizer'
import { Metric } from '../api/Metric'
import { RequestContext } from '../api/RequestContext'
import { TrackProperties } from '../api/TrackProperties'
import { SessionManager } from '../client/SessionManager'
import {
InstrumentationManager,
Expand Down Expand Up @@ -169,7 +170,7 @@ export class ObservabilityClient {

public track(
key: string,
properties?: Attributes,
properties?: TrackProperties,
metricValue?: number,
): void {
if (this.options.disableTraces) return
Expand Down
10 changes: 10 additions & 0 deletions sdk/@launchdarkly/observability-react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,19 @@
* @packageDocumentation
*/

import { setupURLPolyfill } from 'react-native-url-polyfill'
// Imported for documentation.
import { ReactNativeOptions } from './api/Options'

// React Native's built-in `URL` does not implement `.origin`, which the
// OpenTelemetry OTLP HTTP exporter relies on. Without a spec-compliant `URL`,
// every trace/log export throws "URL.origin is not implemented" and is silently
// dropped (regardless of Old/New Architecture). Apply the polyfill at the
// package entry so a working `URL` is guaranteed before any exporter runs.
// (An explicit call is used instead of the `/auto` side-effect import so it
// survives the bundler's aggressive tree-shaking.)
setupURLPolyfill()

export { LDObserve } from './sdk/LDObserve'
export type { Observe } from './api/Observe'
export { Observability } from './plugin/observability'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { LDClientMin, LDPlugin } from './plugin'
import { ReactNativeOptions } from '../api/Options'
import { TrackProperties } from '../api/TrackProperties'
import { flattenTrackProperties } from '../utils/trackAttributes'
import { ObservabilityClient } from '../client/ObservabilityClient'
import { _LDObserve } from '../sdk/LDObserve'
import type {
Expand Down Expand Up @@ -160,14 +162,16 @@ class TracingHook implements Hook {
...(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.
// Flatten user-supplied track data the same way LDObserve.track
// does, so nested objects/arrays survive as dotted attributes
// instead of being dropped by OpenTelemetry.
...(typeof hookContext.data === 'object' &&
hookContext.data !== null
? (hookContext.data as Attributes)
? flattenTrackProperties(
hookContext.data as TrackProperties,
)
: {}),
// Reserved fields are written last so caller data can't clobber them.
key: hookContext.key,
...(hookContext.metricValue !== undefined &&
hookContext.metricValue !== null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Metric } from '../api/Metric'
import { RequestContext } from '../api/RequestContext'
import { Observe } from '../api/Observe'
import { SpanScope, WithSpanOptions } from '../api/SpanScope'
import { TrackProperties } from '../api/TrackProperties'
import { BufferedClass } from './BufferedClass'
import { noOpSpan } from '../utils/NoOpSpan'
import { NOOP_SPAN_OPS, runInSpan, SpanOps } from './withSpan'
Expand Down Expand Up @@ -58,7 +59,11 @@ class LDObserveClass
return this._bufferCall('recordLog', [message, level, attributes])
}

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

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest'
import { flattenTrackProperties } from './trackAttributes'
import type { TrackProperties } from '../api/TrackProperties'

describe('flattenTrackProperties', () => {
it('returns an empty object for undefined', () => {
expect(flattenTrackProperties(undefined)).toEqual({})
})

it('returns an empty object for an empty payload', () => {
// Mirrors Swift `OtelAttributesTests.emptyPayload`.
expect(flattenTrackProperties({})).toEqual({})
})

it('keeps scalar values as-is', () => {
expect(
flattenTrackProperties({
str: 'hello',
num: 42,
float: 3.14,
yes: true,
no: false,
}),
).toEqual({
str: 'hello',
num: 42,
float: 3.14,
yes: true,
no: false,
})
})

it('skips null and undefined values without stringifying', () => {
expect(
flattenTrackProperties({
present: 'x',
missing: null,
absent: undefined,
}),
).toEqual({ present: 'x' })
})

it('drops dates and other unsupported values without stringifying', () => {
// Mirrors Swift `OtelAttributesTests.skipsArbitraryTypes`: values with no
// scalar/array/object attribute form (dates, functions, symbols, bigints)
// are dropped rather than coerced to a string. A Date has no own
// enumerable properties, so it contributes no flattened keys.
const date = new Date(0)
const result = flattenTrackProperties({
keep: 1,
date,
fn: () => undefined,
sym: Symbol('s'),
big: 9n,
} as unknown as TrackProperties)

expect(result).toEqual({ keep: 1 })
expect(result.date).toBeUndefined()
expect(result.date).not.toBe(String(date))
})

it('preserves 64-bit integers without truncation', () => {
// Mirrors Swift `OtelAttributesTests.preservesLong`. The value exceeds
// Int32 range but stays within JS's safe-integer range, so it round-trips
// without loss.
expect(flattenTrackProperties({ id: 9_000_000_000_123 })).toEqual({
id: 9_000_000_000_123,
})
})

it('flattens nested objects with dot-separated keys', () => {
expect(
flattenTrackProperties({
user: { id: '7', tier: 'gold', prefs: { theme: 'dark' } },
}),
).toEqual({
'user.id': '7',
'user.tier': 'gold',
'user.prefs.theme': 'dark',
})
})

it('keeps homogeneous scalar arrays as array attributes', () => {
expect(
flattenTrackProperties({
tags: ['a', 'b'],
flags: [true, false],
sizes: [1, 2, 3],
}),
).toEqual({
tags: ['a', 'b'],
flags: [true, false],
sizes: [1, 2, 3],
})
})

it('flattens arrays of objects with indexed dotted keys', () => {
expect(
flattenTrackProperties({
products: [
{ product_id: 'SKU-1', quantity: 2, price: 24.0 },
{ product_id: 'SKU-2', quantity: 1, price: 24.0 },
],
}),
).toEqual({
'products.0.product_id': 'SKU-1',
'products.0.quantity': 2,
'products.0.price': 24.0,
'products.1.product_id': 'SKU-2',
'products.1.quantity': 1,
'products.1.price': 24.0,
})
})

it('drops empty arrays', () => {
expect(flattenTrackProperties({ empty: [] })).toEqual({})
})

it('converts a Segment "Product Added" flat payload', () => {
// Mirrors Swift `OtelAttributesTests.productAdded` (analytics-taxonomy
// §4.2): a flat e-commerce payload of scalars passes through unchanged.
expect(
flattenTrackProperties({
name: 'Product Added',
product_id: 'SKU-1234',
quantity: 2,
price: 24.0,
currency: 'USD',
cart_id: 'cart_98f1',
}),
).toEqual({
name: 'Product Added',
product_id: 'SKU-1234',
quantity: 2,
price: 24.0,
currency: 'USD',
cart_id: 'cart_98f1',
})
})

it('flattens a Segment "Checkout Started" nested products payload', () => {
// Mirrors Swift `OtelAttributesTests.checkoutStarted`. Swift nests the
// products as an array of AttributeSets; OTel JS has no nested set type,
// so RN flattens them into indexed dotted keys instead.
expect(
flattenTrackProperties({
name: 'Checkout Started',
order_id: 'ord_5521',
value: 72.0,
currency: 'USD',
products: [
{ product_id: 'SKU-1234', quantity: 2, price: 24.0 },
{ product_id: 'SKU-9876', quantity: 1, price: 24.0 },
],
}),
).toEqual({
name: 'Checkout Started',
order_id: 'ord_5521',
value: 72.0,
currency: 'USD',
'products.0.product_id': 'SKU-1234',
'products.0.quantity': 2,
'products.0.price': 24.0,
'products.1.product_id': 'SKU-9876',
'products.1.quantity': 1,
'products.1.price': 24.0,
})
})
})
Loading
Loading