From 06270a8ec9ddd5a40374bcd96a09e24a0ae31f89 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 24 Jun 2026 13:48:37 -0700 Subject: [PATCH 01/21] docs(observability-react-native): add distributed tracing guide Add a cookbook of distributed tracing patterns for the React Native observability SDK, mirroring the existing .NET MAUI guide but adapted to the JS/OpenTelemetry API (startActiveSpan/startSpan, explicit context propagation across async boundaries, tracingOrigins-based mobile-to-backend trace linking). Lives in guides/ since docs/ is reserved for generated Typedoc output, and is linked from the package README. Co-authored-by: Cursor --- .../observability-react-native/README.md | 4 + .../guides/distributed-tracing.md | 502 ++++++++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md diff --git a/sdk/@launchdarkly/observability-react-native/README.md b/sdk/@launchdarkly/observability-react-native/README.md index d1126a6fb3..846e56b935 100644 --- a/sdk/@launchdarkly/observability-react-native/README.md +++ b/sdk/@launchdarkly/observability-react-native/README.md @@ -39,6 +39,10 @@ const client = new LDClient( ); ``` +## Guides + +- [Distributed Tracing Guide](guides/distributed-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. diff --git a/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md b/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md new file mode 100644 index 0000000000..d9a24694be --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md @@ -0,0 +1,502 @@ +# Distributed Tracing Guide + +This cookbook covers common distributed tracing patterns with the LaunchDarkly Observability SDK for React Native. Each recipe is self-contained and demonstrates a single concept with realistic examples. + +All examples assume the SDK has already been initialized (see [Usage](../README.md#usage)) and the following imports are present: + +```typescript +import { LDObserve } from '@launchdarkly/observability-react-native' +import { context, trace, SpanStatusCode } from '@opentelemetry/api' +``` + +Spans returned by the SDK are standard OpenTelemetry [`Span`](https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Span.html) objects, so every span operation in this guide is the regular OpenTelemetry API. + +> **A note on context propagation in React Native.** Unlike .NET's `AsyncLocal`, JavaScript does not automatically carry the active span across `await`, `setTimeout`, `Promise` callbacks, or event handlers in React Native. `startActiveSpan` makes a span active only for the synchronous portion of its callback (and any `await`s within the same callback). When you cross into a *detached* callback, you must pass context explicitly — see recipes 7 and 8. + +--- + +## 1. Start a Root Span + +Create an independent span that begins a brand-new trace by passing `{ root: true }`. Use `startActiveSpan` so the span is active for the duration of the callback and ends automatically when the callback returns. + +```typescript +LDObserve.startActiveSpan( + 'app-cold-start', + (span) => { + span.setAttribute('launch_type', 'cold') + span.setAttribute('device_model', Platform.constants.Model ?? 'unknown') + span.addEvent('splash_rendered') + + // ... initialization work ... + + span.addEvent('home_screen_ready') + }, + { root: true }, +) +``` + +If you need to control the span's lifetime manually (for example, it ends in a different function), use `startSpan` and call `span.end()` yourself: + +```typescript +const span = LDObserve.startSpan('app-cold-start', { root: true }) +span.setAttribute('launch_type', 'cold') +// ... later ... +span.end() +``` + +Use `{ root: true }` when you want a span that is guaranteed to start a new trace, regardless of any ambient context. + +--- + +## 2. Nested Spans for a Typical React Native Workflow + +`startActiveSpan` automatically parents each new span under the currently active one. This is the most common pattern for tracing multi-step operations. Because the parent stays active for the entire `async` callback, spans started during `await`s nest correctly. + +```typescript +async function loadProducts() { + return LDObserve.startActiveSpan('LoadProducts', async (loadSpan) => { + const products = await LDObserve.startActiveSpan( + 'FetchFromApi', + async (fetchSpan) => { + const response = await fetch('https://api.example.com/products') + fetchSpan.setAttribute('http.status_code', response.status) + const json = await response.text() + + return LDObserve.startActiveSpan('DeserializeJson', (parseSpan) => { + const parsed = JSON.parse(json) as Product[] + parseSpan.setAttribute('product_count', parsed.length) + return parsed + }) + }, + ) + + LDObserve.startActiveSpan('RenderUI', (renderSpan) => { + renderSpan.setAttribute('product_count', products.length) + setProducts(products) + }) + + return products + }) +} +``` + +The resulting trace tree: + +```mermaid +graph TD + A[LoadProducts] --> B[FetchFromApi] + B --> C[DeserializeJson] + A --> D[RenderUI] +``` + +--- + +## 3. HTTP Call Span with Manual Error Handling + +Wrap a network call in a span to capture timing, status codes, and errors in a single trace unit. + +```typescript +async function fetchUserProfile(userId: string): Promise { + return LDObserve.startActiveSpan('FetchUserProfile', async (span) => { + span.setAttribute('user.id', userId) + span.setAttribute('http.method', 'GET') + + try { + const url = `https://api.example.com/users/${userId}` + span.setAttribute('http.url', url) + + const response = await fetch(url) + span.setAttribute('http.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR }) + span.setAttribute('error.type', `HTTP ${response.status}`) + return null + } + + const profile = (await response.json()) as UserProfile + span.setStatus({ code: SpanStatusCode.OK }) + return profile + } catch (err) { + span.recordException(err as Error) + span.setStatus({ code: SpanStatusCode.ERROR }) + throw err + } finally { + span.end() + } + }) +} +``` + +> When you call `startActiveSpan`, the span ends automatically once the callback's returned promise settles — but ending it yourself in a `finally` block is harmless and makes the lifetime explicit. + +--- + +## 4. Automatic `fetch` / `XMLHttpRequest` Instrumentation Under a Custom Parent + +The SDK auto-instruments `fetch` and `XMLHttpRequest` (unless `disableTraces` is set). Every network request generates its own span automatically. If a custom span is active at call time, the auto-generated HTTP span becomes its child. + +```typescript +async function syncOrders() { + await LDObserve.startActiveSpan('SyncOrders', async (span) => { + span.setAttribute('sync.direction', 'pull') + + // The HTTP span for this fetch is auto-created as a child of "SyncOrders" + const response = await fetch('https://api.example.com/orders?since=yesterday') + span.setAttribute('http.status_code', response.status) + + const orders = (await response.json()) as Order[] + span.setAttribute('order_count', orders.length) + }) +} +``` + +The resulting trace tree: + +```mermaid +graph TD + A[SyncOrders] --> B["HTTP GET /orders (auto-instrumented)"] +``` + +You do not need to create a span for the HTTP call itself; the SDK handles it. Your business-logic span provides the parent context. The key requirement is that the request happens while your span is **active** — i.e. inside a `startActiveSpan` callback. + +--- + +## 5. Record Exception and Mark Span as Failed + +Use `span.recordException` to attach structured error data to a span, then `span.setStatus` to mark it as failed. Pair it with `LDObserve.recordError` to surface the error in your error backend. + +```typescript +async function processPayment(orderId: string, amount: number) { + await LDObserve.startActiveSpan('ProcessPayment', async (span) => { + span.setAttribute('order.id', orderId) + span.setAttribute('payment.amount', amount) + + try { + const result = await paymentGateway.charge(orderId, amount) + span.setAttribute('payment.transaction_id', result.transactionId) + span.setStatus({ code: SpanStatusCode.OK }) + } catch (err) { + const error = err as Error + span.recordException(error) + span.setStatus({ code: SpanStatusCode.ERROR }) + span.setAttribute('error.category', error.name) + + // Pass the active span so the error attaches to it instead of a new one + LDObserve.recordError(error, { 'order.id': orderId }, { span }) + throw err + } + }) +} +``` + +`span.recordException` adds an `exception` event with `exception.type`, `exception.message`, and `exception.stacktrace` attributes following the OpenTelemetry semantic conventions. Passing `{ span }` to `recordError` records the error against the span you already created; omit it and the SDK attaches the error to the currently active span (or creates a short-lived one). + +--- + +## 6. Correlated Logs Inside the Active Span + +When a span is active, `LDObserve.recordLog` automatically picks up the ambient trace and span IDs from the active context. No extra work is needed to correlate them. + +```typescript +async function importCatalog(rows: AsyncIterable) { + await LDObserve.startActiveSpan('ImportCatalog', async (span) => { + LDObserve.recordLog('Import started', 'info', { source: 'csv' }) + + let imported = 0 + for await (const row of rows) { + await db.upsertProduct(row) + imported++ + } + + span.setAttribute('imported_count', imported) + + LDObserve.recordLog('Import completed', 'info', { imported_count: imported }) + }) +} +``` + +Both log records carry the same `traceId` and `spanId` as the `ImportCatalog` span, linking them together in your observability backend. + +--- + +## 7. Re-establishing Context to Correlate Logs Across Async Boundaries + +The active context is not automatically restored inside a detached callback such as `setTimeout`, a timer, or an event handler that runs after the original `startActiveSpan` callback has returned. Capture the span's context with `LDObserve.getContextFromSpan` and re-activate it with `context.with` so the log correlates with the right span. + +```typescript +function onUploadPressed() { + const span = LDObserve.startSpan('UploadReport') + span.setAttribute('report.type', 'daily') + const capturedContext = LDObserve.getContextFromSpan(span) + span.end() + + setTimeout(() => { + // The active context is empty here -- re-establish it explicitly. + context.with(capturedContext, () => { + LDObserve.recordLog('Upload processing on background tick', 'info', { + phase: 'start', + }) + + // ... heavy processing ... + + LDObserve.recordLog('Upload complete', 'info', { phase: 'end' }) + }) + }, 0) +} +``` + +Every `recordLog` call made inside `context.with(capturedContext, ...)` is stamped with the `UploadReport` span's `traceId` and `spanId`. + +--- + +## 8. Creating a Child Span Where Automatic Propagation Won't Work + +Sometimes you need a full child *span* (not just a correlated log) in a callback where the active context has been lost. Both `startSpan` and `startActiveSpan` accept an explicit parent `Context` as their final argument. Capture the parent's context with `getContextFromSpan` and pass it through. + +```typescript +function startBackgroundSync() { + LDObserve.startActiveSpan('ScheduleSync', (parentSpan) => { + parentSpan.setAttribute('sync.mode', 'background') + const parentContext = LDObserve.getContextFromSpan(parentSpan) + + // setTimeout drops the active context + setTimeout(async () => { + await LDObserve.startActiveSpan( + 'BackgroundSync', + async (childSpan) => { + const response = await fetch('https://api.example.com/sync') + childSpan.setAttribute('http.status_code', response.status) + childSpan.addEvent('sync.complete') + }, + undefined, // no extra span options + parentContext, // explicit parent context + ) + }, 0) + }) +} +``` + +The same technique applies to recurring timer callbacks: + +```typescript +function startPolling() { + const span = LDObserve.startSpan('StartPolling') + const parentContext = LDObserve.getContextFromSpan(span) + span.end() + + setInterval(() => { + // Interval callbacks run with no ambient span context + LDObserve.startActiveSpan( + 'PollTick', + (pollSpan) => { + pollSpan.setAttribute('tick.time', new Date().toISOString()) + // ... polling logic ... + }, + undefined, + parentContext, + ) + }, 30_000) +} +``` + +The resulting trace has `StartPolling` as the short-lived parent, with each `PollTick` appearing as a child span fired at 30-second intervals. + +If you need a span that is *not* active but still parented explicitly, use `startSpan`: + +```typescript +const childSpan = LDObserve.startSpan('DetachedWork', undefined, parentContext) +// ... do work ... +childSpan.end() +``` + +--- + +## 9. Sequential Independent Root Spans + +Use `{ root: true }` to create spans that each begin a separate trace. This is useful for batch operations or analytics events where each item should be its own trace. + +```typescript +function processAnalyticsQueue(events: AnalyticsEvent[]) { + for (const evt of events) { + LDObserve.startActiveSpan( + `Analytics:${evt.type}`, + (span) => { + span.setAttribute('event.type', evt.type) + span.setAttribute('event.timestamp', evt.timestamp) + span.setAttribute('event.user_id', evt.userId) + + try { + analyticsService.process(evt) + span.setStatus({ code: SpanStatusCode.OK }) + } catch (err) { + span.recordException(err as Error) + span.setStatus({ code: SpanStatusCode.ERROR }) + } + }, + { root: true }, + ) + } +} +``` + +Each iteration creates an independent trace. Without `{ root: true }`, successive `startActiveSpan` calls would nest under whatever span is currently active. + +--- + +## 10. Span Events as Lightweight Checkpoints + +Use `span.addEvent` to mark milestones within a long-running span without creating child spans. Events are cheaper than spans and ideal for logging progress through a linear pipeline. + +```typescript +async function downloadAndCacheImage(url: string) { + await LDObserve.startActiveSpan('DownloadAndCacheImage', async (span) => { + span.setAttribute('image.url', url) + + span.addEvent('download.started') + const response = await fetch(url) + const blob = await response.blob() + span.addEvent('download.completed') + + span.setAttribute('image.size_bytes', blob.size) + + span.addEvent('cache.write.started') + const path = `${FileSystem.cacheDirectory}${fileNameFromUrl(url)}` + await writeBlobToFile(path, blob) + span.addEvent('cache.write.completed') + + span.setAttribute('cache.path', path) + }) +} +``` + +You can also attach attributes to an event: `span.addEvent('cache.write.completed', { bytes: blob.size })`. + +--- + +## 11. Connecting Mobile Traces to Your Backend (End-to-End Distributed Tracing) + +The real power of distributed tracing is linking a span on the device to the spans your backend produces for the same request. The SDK does this automatically by injecting a W3C `traceparent` header into outgoing requests — but **only** for URLs you opt in via the `tracingOrigins` option. This prevents leaking trace headers to third-party domains. + +```typescript +new Observability({ + serviceName: 'my-react-native-app', + // Attach trace headers to requests whose URL matches any of these entries. + tracingOrigins: ['api.example.com', /\.internal\.example\.com$/], +}) +``` + +With `tracingOrigins` configured, any `fetch`/`XHR` request to a matching host carries a `traceparent` header, so the backend continues the same trace: + +```typescript +async function checkout(cartId: string) { + await LDObserve.startActiveSpan('Checkout', async (span) => { + span.setAttribute('cart.id', cartId) + + // traceparent is injected automatically because api.example.com is a tracing origin. + // The backend span becomes a child of "Checkout" in the same trace. + const response = await fetch('https://api.example.com/checkout', { + method: 'POST', + body: JSON.stringify({ cartId }), + }) + span.setAttribute('http.status_code', response.status) + }) +} +``` + +```mermaid +graph TD + A["Checkout (mobile)"] --> B["POST /checkout (auto-instrumented, mobile)"] + B --> C["/checkout handler (backend)"] + C --> D["charge-payment (backend)"] +``` + +### Continuing a trace from incoming headers + +If your app receives work along with trace headers (for example, a push payload or a webhook-style message that carries `x-request-id` / `x-session-id`), use the header helpers to start a span annotated with that context: + +```typescript +// Run a callback inside a span that records the incoming headers as attributes. +LDObserve.runWithHeaders('HandlePushPayload', incomingHeaders, (span) => { + span.setAttribute('payload.kind', payload.kind) + handlePayload(payload) +}) + +// Or get a span you end yourself: +const span = LDObserve.startWithHeaders('HandlePushPayload', incomingHeaders) +// ... work ... +span.end() + +// Extract just the correlation IDs without starting a span: +const requestContext = LDObserve.parseHeaders(incomingHeaders) +// requestContext.sessionId, requestContext.requestId +``` + +### Suppressing propagation for specific URLs + +Use `urlBlocklist` to ensure trace headers are never attached (and request bodies/headers are never recorded) for sensitive endpoints, even if they would otherwise match `tracingOrigins`: + +```typescript +new Observability({ + tracingOrigins: ['api.example.com'], + urlBlocklist: ['api.example.com/auth', 'api.example.com/payment'], +}) +``` + +--- + +## Quick Reference + +### Span Creation (`LDObserve`) + +| Method | Parent | Returns | +|---|---|---| +| `startActiveSpan(name, fn, options?, ctx?)` | Current active span (or `ctx` if provided) | result of `fn` | +| `startActiveSpan(name, fn, { root: true })` | None (new trace) | result of `fn` | +| `startSpan(name, options?, ctx?)` | Current active span (or `ctx`); span is **not** made active | `Span` | +| `startSpan(name, { root: true })` | None (new trace) | `Span` | +| `runWithHeaders(name, headers, cb, options?)` | Current active span; records `http.header.*` attributes | result of `cb` | +| `startWithHeaders(name, headers, options?)` | Current active span; records `http.header.*` attributes | `Span` | +| `getContextFromSpan(span)` | -- | `Context` (for explicit re-parenting) | +| `parseHeaders(headers)` | -- | `RequestContext` (`sessionId`, `requestId`) | + +> `startActiveSpan` ends its span automatically when the callback returns (awaiting the returned promise). `startSpan` requires a manual `span.end()`. + +### Span Operations (OpenTelemetry `Span`) + +| Method | Description | +|---|---| +| `span.setAttribute(key, value)` | Set a single key-value attribute on the span | +| `span.setAttributes({ ... })` | Set multiple attributes at once | +| `span.addEvent(name, attributes?)` | Record a named event (lightweight checkpoint) | +| `span.recordException(error)` | Attach exception details as a span event | +| `span.setStatus({ code: SpanStatusCode.OK })` | Mark the span as successful | +| `span.setStatus({ code: SpanStatusCode.ERROR })` | Mark the span as failed | +| `span.spanContext()` | Get the raw `SpanContext` (`traceId`, `spanId`, `traceFlags`) | +| `span.updateName(name)` | Rename the span | +| `span.end()` | Manually end the span | + +### Logs, Errors, and Metrics (`LDObserve`) + +| Method | Description | +|---|---| +| `recordLog(message, level, attributes?)` | Emit a structured log; auto-correlates with the active span | +| `recordError(error, attributes?, { span }?)` | Record an error; attaches to `span`, else the active span | +| `recordMetric(metric)` | Record a gauge value | +| `recordCount(metric)` | Add to a counter | +| `recordIncr(metric)` | Increment a counter by 1 | +| `recordHistogram(metric)` | Record a histogram value | +| `recordUpDownCounter(metric)` | Add to an up/down counter | +| `flush()` | Flush all pending telemetry (`Promise`) | +| `getSessionInfo()` | Get the current session info | +| `isInitialized()` | Whether the observability client is ready | + +> A `Metric` is `{ name: string; value: number; attributes?: Attributes; timestamp?: number }`. + +### Context Helpers (`@opentelemetry/api`) + +| Helper | Description | +|---|---| +| `trace.getActiveSpan()` | Get the currently active span, if any | +| `context.active()` | Get the currently active context | +| `context.with(ctx, fn)` | Run `fn` with `ctx` as the active context (re-establish across async boundaries) | From 894e208f00d163e1f98d28a87362abcb4245a416 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 24 Jun 2026 14:12:32 -0700 Subject: [PATCH 02/21] docs(observability-react-native): add OTel baggage example to tracing guide Document setting, reading, and updating W3C baggage and how it propagates to backends via tracingOrigins, plus a note that baggage is not copied onto spans automatically. Co-authored-by: Cursor --- .../guides/distributed-tracing.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md b/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md index d9a24694be..d09e837866 100644 --- a/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md +++ b/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md @@ -445,6 +445,65 @@ new Observability({ --- +## 12. Propagating Baggage Across Spans and Services + +Baggage is a set of key-value pairs that travels with the active context — across child spans and, when a request targets one of your `tracingOrigins`, across the network to your backend via the W3C `baggage` header. The SDK registers a `W3CBaggagePropagator` by default, so you only need the standard OpenTelemetry baggage API. + +Add the import: + +```typescript +import { propagation } from '@opentelemetry/api' +``` + +Set baggage and run work within it. Spans started inside the callback — and outgoing `fetch`/`XHR` requests to tracing origins — carry these entries: + +```typescript +function withTenantContext(tenantId: string, tier: string, fn: () => T): T { + const baggage = propagation.createBaggage({ + 'app.tenant_id': { value: tenantId }, + 'app.user_tier': { value: tier }, + }) + return context.with(propagation.setBaggage(context.active(), baggage), fn) +} + +withTenantContext('acme', 'gold', () => { + LDObserve.startActiveSpan('LoadDashboard', async (span) => { + // api.example.com is a tracing origin -> the `baggage` header + // (app.tenant_id=acme,app.user_tier=gold) is sent to the backend. + await fetch('https://api.example.com/dashboard') + }) +}) +``` + +Read baggage anywhere it is active: + +```typescript +const current = propagation.getActiveBaggage() +const tenantId = current?.getEntry('app.tenant_id')?.value +``` + +Add to (or update) existing baggage without dropping what is already there — `Baggage` is immutable, so each change returns a new instance: + +```typescript +const existing = propagation.getActiveBaggage() ?? propagation.createBaggage() +const updated = existing.setEntry('app.experiment', { value: 'new-checkout' }) + +context.with(propagation.setBaggage(context.active(), updated), () => { + // ... work that should see the added entry ... +}) +``` + +> **Baggage is not automatically copied onto spans.** It rides along the context and is sent to the backend, but it does not become span attributes unless you put it there. Copy the entries you want to query on explicitly: +> +> ```typescript +> const bag = propagation.getActiveBaggage() +> bag?.getAllEntries().forEach(([key, entry]) => span.setAttribute(key, entry.value)) +> ``` + +Like trace headers, the `baggage` header is only attached to requests whose URL matches `tracingOrigins`, and `urlBlocklist` suppresses it. Avoid putting sensitive data in baggage, and keep entries small — they are sent on every matching request. + +--- + ## Quick Reference ### Span Creation (`LDObserve`) @@ -500,3 +559,7 @@ new Observability({ | `trace.getActiveSpan()` | Get the currently active span, if any | | `context.active()` | Get the currently active context | | `context.with(ctx, fn)` | Run `fn` with `ctx` as the active context (re-establish across async boundaries) | +| `propagation.getActiveBaggage()` | Get the baggage from the active context, if any | +| `propagation.createBaggage(entries?)` | Create a `Baggage` (e.g. `{ key: { value } }`) | +| `propagation.setBaggage(ctx, baggage)` | Return a new context with the baggage attached | +| `baggage.setEntry` / `getEntry` / `getAllEntries` | Read or modify baggage entries (immutable; returns a new `Baggage`) | From 351b72dcc1d62d73f583feddd35a0bbded654c44 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 24 Jun 2026 14:27:50 -0700 Subject: [PATCH 03/21] docs(observability-react-native): add Tracing tab to example app Add a Tracing tab to the session-replay example with a button per recipe from the distributed tracing guide and a live output log, registering the Observability plugin with tracingOrigins so the backend-propagation and baggage recipes exercise real header injection. Co-authored-by: Cursor --- .../example/package.json | 2 + .../example/src/App.tsx | 26 +- .../example/src/TracingScreen.tsx | 593 ++++++++++++++++++ 3 files changed, 618 insertions(+), 3 deletions(-) create mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json b/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json index feb5d53dc2..362435ec9a 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/package.json @@ -10,8 +10,10 @@ "build:ios": "react-native build-ios --mode Debug" }, "dependencies": { + "@launchdarkly/observability-react-native": "workspace:*", "@launchdarkly/react-native-client-sdk": "^10.12.5", "@launchdarkly/session-replay-react-native": "workspace:*", + "@opentelemetry/api": "^1.9.0", "react": "19.2.0", "react-native": "0.83.0", "react-native-webview": "13.16.1" diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx index 6e6b9eee57..300f486524 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx @@ -12,8 +12,10 @@ import { } from '@launchdarkly/react-native-client-sdk'; import { useEffect, useState } from 'react'; import { createSessionReplayPlugin } from '@launchdarkly/session-replay-react-native'; +import { Observability } from '@launchdarkly/observability-react-native'; import DialogsScreen from './DialogsScreen'; import MaskingScreen from './MaskingScreen'; +import TracingScreen from './TracingScreen'; const plugin = createSessionReplayPlugin({ isEnabled: true, @@ -26,17 +28,28 @@ const plugin = createSessionReplayPlugin({ minimumAlpha: 0.05, }); +// The observability plugin powers the distributed tracing examples on the +// "Tracing" tab. `tracingOrigins` opts the demo API hosts into W3C +// `traceparent` / `baggage` header propagation so device spans can link to a +// backend trace (see the distributed-tracing guide, sections 11 and 12). +const observability = new Observability({ + serviceName: 'session-replay-rn-example', + serviceVersion: '1.0.0', + debug: true, + tracingOrigins: ['jsonplaceholder.typicode.com', 'reactnative.dev'], +}); + // Replace with your LaunchDarkly mobile key. // You can also set the LAUNCHDARKLY_MOBILE_KEY environment variable. const MOBILE_KEY = process.env.LAUNCHDARKLY_MOBILE_KEY || 'YOUR_LAUNCHDARKLY_MOBILE_KEY_HERE'; const client = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, { - plugins: [plugin], + plugins: [plugin, observability], }); const context = { kind: 'user', key: 'user-key-123abc' }; -type Tab = 'masking' | 'dialogs'; +type Tab = 'masking' | 'dialogs' | 'tracing'; export default function App() { const [tab, setTab] = useState('masking'); @@ -59,8 +72,15 @@ export default function App() { active={tab === 'dialogs'} onPress={() => setTab('dialogs')} /> + setTab('tracing')} + /> - {tab === 'masking' ? : } + {tab === 'masking' && } + {tab === 'dialogs' && } + {tab === 'tracing' && } ); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx new file mode 100644 index 0000000000..ca60a8e851 --- /dev/null +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx @@ -0,0 +1,593 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { LDObserve } from '@launchdarkly/observability-react-native'; +import { + context, + propagation, + SpanStatusCode, + trace, + type Span, +} from '@opentelemetry/api'; + +/** + * Manual test screen that exercises every recipe from the React Native + * distributed tracing guide: + * sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md + * + * Each button runs one recipe and appends a line (with the resulting trace/span + * IDs where relevant) to the on-screen log. The observability plugin is wired up + * in App.tsx with `tracingOrigins` set to the demo API hosts used below, so the + * backend-propagation recipes (11, 12) actually inject `traceparent` / `baggage` + * headers. + * + * Note: with a placeholder mobile key the SDK still creates spans locally; export + * to LaunchDarkly only happens once a real key is configured. + */ + +// Public demo endpoints. jsonplaceholder is listed in `tracingOrigins`, so +// requests to it carry trace headers; the tiny logo is used for the +// download/cache checkpoint recipe. +const POSTS_URL = 'https://jsonplaceholder.typicode.com/posts'; +const USERS_URL = 'https://jsonplaceholder.typicode.com/users'; +const CHECKOUT_URL = 'https://jsonplaceholder.typicode.com/posts'; +const IMAGE_URL = 'https://reactnative.dev/img/tiny_logo.png'; + +const short = (id?: string) => (id ? id.slice(0, 8) : 'n/a'); +const traceOf = (span: Span) => short(span.spanContext().traceId); + +export default function TracingScreen() { + const [lines, setLines] = useState([]); + const intervalRef = useRef | null>(null); + + const log = (msg: string) => + setLines((prev) => + [`${new Date().toLocaleTimeString()} ${msg}`, ...prev].slice(0, 200) + ); + + // Wrap each recipe so a thrown error is surfaced in the log instead of + // crashing the screen. + const run = (label: string, fn: () => void | Promise) => async () => { + try { + await fn(); + } catch (err) { + log(`✗ ${label} threw: ${(err as Error).message}`); + } + }; + + useEffect( + () => () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }, + [] + ); + + // -- 1. Root span -------------------------------------------------------- + const rootSpan = () => { + LDObserve.startActiveSpan( + 'app-cold-start', + (span) => { + span.setAttribute('launch_type', 'cold'); + span.setAttribute('device_os', Platform.OS); + span.addEvent('splash_rendered'); + span.addEvent('home_screen_ready'); + log(`[1] root span "app-cold-start" trace=${traceOf(span)}`); + }, + { root: true } + ); + }; + + // -- 2. Nested spans ----------------------------------------------------- + const nestedSpans = async () => { + await LDObserve.startActiveSpan('LoadProducts', async () => { + const items = await LDObserve.startActiveSpan( + 'FetchFromApi', + async (fetchSpan) => { + const response = await fetch(POSTS_URL); + fetchSpan.setAttribute('http.status_code', response.status); + const json = await response.text(); + return LDObserve.startActiveSpan('DeserializeJson', (parseSpan) => { + const parsed = JSON.parse(json) as unknown[]; + parseSpan.setAttribute('product_count', parsed.length); + return parsed; + }); + } + ); + LDObserve.startActiveSpan('RenderUI', (renderSpan) => { + renderSpan.setAttribute('product_count', items.length); + }); + log(`[2] LoadProducts > FetchFromApi > DeserializeJson (${items.length})`); + }); + }; + + // -- 3. HTTP span with manual error handling ----------------------------- + const httpWithErrorHandling = async () => { + await LDObserve.startActiveSpan('FetchUserProfile', async (span) => { + span.setAttribute('user.id', '1'); + span.setAttribute('http.method', 'GET'); + try { + const url = `${USERS_URL}/1`; + span.setAttribute('http.url', url); + const response = await fetch(url); + span.setAttribute('http.status_code', response.status); + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR }); + span.setAttribute('error.type', `HTTP ${response.status}`); + log(`[3] FetchUserProfile failed: HTTP ${response.status}`); + return; + } + await response.json(); + span.setStatus({ code: SpanStatusCode.OK }); + log(`[3] FetchUserProfile OK trace=${traceOf(span)}`); + } catch (err) { + span.recordException(err as Error); + span.setStatus({ code: SpanStatusCode.ERROR }); + log(`[3] FetchUserProfile threw: ${(err as Error).message}`); + } finally { + span.end(); + } + }); + }; + + // -- 4. Auto fetch instrumentation under a custom parent ----------------- + const autoInstrumentedChild = async () => { + await LDObserve.startActiveSpan('SyncOrders', async (span) => { + span.setAttribute('sync.direction', 'pull'); + // The auto-instrumented fetch span becomes a child of SyncOrders. + const response = await fetch(`${POSTS_URL}?_limit=5`); + span.setAttribute('http.status_code', response.status); + const orders = (await response.json()) as unknown[]; + span.setAttribute('order_count', orders.length); + log(`[4] SyncOrders + auto HTTP child (${orders.length} orders)`); + }); + }; + + // -- 5. Record exception and mark span failed ---------------------------- + const recordException = async () => { + await LDObserve.startActiveSpan('ProcessPayment', async (span) => { + span.setAttribute('order.id', 'order-42'); + span.setAttribute('payment.amount', 19.99); + try { + // Simulate a gateway failure. + throw new Error('Payment gateway timeout'); + } catch (err) { + const error = err as Error; + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + span.setAttribute('error.category', error.name); + LDObserve.recordError(error, { 'order.id': 'order-42' }, { span }); + log(`[5] ProcessPayment recorded exception: ${error.message}`); + } + }); + }; + + // -- 6. Correlated logs inside the active span --------------------------- + const correlatedLogs = async () => { + await LDObserve.startActiveSpan('ImportCatalog', async (span) => { + LDObserve.recordLog('Import started', 'info', { source: 'demo' }); + let imported = 0; + for (const _row of [1, 2, 3, 4, 5]) { + await new Promise((r) => setTimeout(r, 5)); + imported++; + } + span.setAttribute('imported_count', imported); + LDObserve.recordLog('Import completed', 'info', { + imported_count: imported, + }); + log(`[6] ImportCatalog: 2 correlated logs, trace=${traceOf(span)}`); + }); + }; + + // -- 7. Re-establish context to correlate logs across async boundaries ---- + const correlateAcrossAsync = () => { + const span = LDObserve.startSpan('UploadReport'); + span.setAttribute('report.type', 'daily'); + const capturedContext = LDObserve.getContextFromSpan(span); + const tid = traceOf(span); + span.end(); + + setTimeout(() => { + // Active context is empty here -- re-establish it explicitly. + context.with(capturedContext, () => { + LDObserve.recordLog('Upload processing on background tick', 'info', { + phase: 'start', + }); + LDObserve.recordLog('Upload complete', 'info', { phase: 'end' }); + }); + log(`[7] Re-established context for logs, trace=${tid}`); + }, 0); + }; + + // -- 8a. Child span where automatic propagation won't work --------------- + const detachedChildSpan = () => { + LDObserve.startActiveSpan('ScheduleSync', (parentSpan) => { + parentSpan.setAttribute('sync.mode', 'background'); + const parentContext = LDObserve.getContextFromSpan(parentSpan); + const tid = traceOf(parentSpan); + + // setTimeout drops the active context -> pass it explicitly. + setTimeout(async () => { + await LDObserve.startActiveSpan( + 'BackgroundSync', + async (childSpan) => { + const response = await fetch(`${POSTS_URL}/1`); + childSpan.setAttribute('http.status_code', response.status); + childSpan.addEvent('sync.complete'); + }, + undefined, + parentContext + ); + log(`[8a] BackgroundSync re-parented to ScheduleSync trace=${tid}`); + }, 0); + }); + }; + + // -- 8b. Bounded polling with re-parented tick spans --------------------- + const startBoundedPolling = () => { + if (intervalRef.current) { + log('[8b] polling already running'); + return; + } + const span = LDObserve.startSpan('StartPolling'); + const parentContext = LDObserve.getContextFromSpan(span); + const tid = traceOf(span); + span.end(); + + let ticks = 0; + intervalRef.current = setInterval(() => { + ticks++; + LDObserve.startActiveSpan( + 'PollTick', + (pollSpan) => { + pollSpan.setAttribute('tick.time', new Date().toISOString()); + pollSpan.setAttribute('tick.number', ticks); + }, + undefined, + parentContext + ); + log(`[8b] PollTick #${ticks} parent trace=${tid}`); + if (ticks >= 3 && intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, 1000); + }; + + // -- 9. Sequential independent root spans -------------------------------- + const independentRootSpans = () => { + const events = [ + { type: 'view', userId: 'u1' }, + { type: 'click', userId: 'u2' }, + { type: 'purchase', userId: 'u3' }, + ]; + const traces: string[] = []; + for (const evt of events) { + LDObserve.startActiveSpan( + `Analytics:${evt.type}`, + (span) => { + span.setAttribute('event.type', evt.type); + span.setAttribute('event.timestamp', new Date().toISOString()); + span.setAttribute('event.user_id', evt.userId); + span.setStatus({ code: SpanStatusCode.OK }); + traces.push(traceOf(span)); + }, + { root: true } + ); + } + log(`[9] 3 independent traces: ${traces.join(', ')}`); + }; + + // -- 10. Span events as lightweight checkpoints -------------------------- + const spanEvents = async () => { + await LDObserve.startActiveSpan('DownloadAndCacheImage', async (span) => { + span.setAttribute('image.url', IMAGE_URL); + span.addEvent('download.started'); + const response = await fetch(IMAGE_URL); + const blob = await response.blob(); + span.addEvent('download.completed'); + span.setAttribute('image.size_bytes', blob.size); + span.addEvent('cache.write.started'); + // (no real filesystem write in the demo) + span.addEvent('cache.write.completed', { bytes: blob.size }); + log(`[10] DownloadAndCacheImage: ${blob.size} bytes, 4 events`); + }); + }; + + // -- 11. Connecting mobile traces to your backend ------------------------ + const backendDistributedTrace = async () => { + await LDObserve.startActiveSpan('Checkout', async (span) => { + span.setAttribute('cart.id', 'cart-7'); + // traceparent is injected automatically because the host is a tracing + // origin -> a backend span would join this trace. + const response = await fetch(CHECKOUT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cartId: 'cart-7' }), + }); + span.setAttribute('http.status_code', response.status); + log(`[11] Checkout POST (traceparent propagated) trace=${traceOf(span)}`); + }); + }; + + // -- 11b. Continuing a trace from incoming headers ----------------------- + const incomingHeaders = () => { + const headers = { + 'x-session-id': 'sess-abc', + 'x-request-id': 'req-123', + }; + LDObserve.runWithHeaders('HandlePushPayload', headers, (span) => { + span.setAttribute('payload.kind', 'promo'); + log(`[11b] runWithHeaders span, trace=${traceOf(span)}`); + }); + const ctx = LDObserve.parseHeaders(headers); + log(`[11b] parseHeaders -> session=${ctx.sessionId} req=${ctx.requestId}`); + }; + + // -- 12. Propagating baggage -------------------------------------------- + const baggage = async () => { + const bag = propagation.createBaggage({ + 'app.tenant_id': { value: 'acme' }, + 'app.user_tier': { value: 'gold' }, + }); + await context.with( + propagation.setBaggage(context.active(), bag), + async () => { + // Read it back. + const tenant = propagation + .getActiveBaggage() + ?.getEntry('app.tenant_id')?.value; + + await LDObserve.startActiveSpan('LoadDashboard', async (span) => { + // Baggage is not copied onto spans automatically -- do it explicitly. + propagation + .getActiveBaggage() + ?.getAllEntries() + .forEach(([key, entry]) => span.setAttribute(key, entry.value)); + + // Outgoing request to a tracing origin also carries the baggage header. + await fetch(`${POSTS_URL}/1`); + log(`[12] baggage tenant=${tenant} copied onto LoadDashboard span`); + }); + } + ); + }; + + // -- Utilities ----------------------------------------------------------- + const showSessionInfo = () => { + const info = LDObserve.getSessionInfo(); + const active = trace.getActiveSpan(); + log( + `[i] initialized=${LDObserve.isInitialized()} session=${JSON.stringify( + info + )} activeSpan=${active ? 'yes' : 'none'}` + ); + }; + + const flush = run('flush', async () => { + await LDObserve.flush(); + log('[i] flushed telemetry'); + }); + + return ( + + + + Each button runs one recipe from the distributed tracing guide. Results + (with trace IDs) appear in the log below. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setLines([])} variant="danger" /> + + + + + Output log + + {lines.length === 0 ? ( + No output yet — tap a button. + ) : ( + lines.map((line, i) => ( + + {line} + + )) + )} + + + + ); +} + +function SectionHeader({ + title, + topSpacing, +}: { + title: string; + topSpacing?: boolean; +}) { + return ( + <> + + {title} + + + + ); +} + +function Btn({ + label, + onPress, + variant, +}: { + label: string; + onPress: () => void; + variant?: 'default' | 'danger'; +}) { + return ( + + {label} + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + scroll: { + padding: 16, + paddingBottom: 24, + }, + intro: { + color: '#CAC4D0', + fontSize: 14, + fontStyle: 'italic', + marginBottom: 12, + }, + sectionTitle: { + color: '#fff', + fontSize: 20, + fontWeight: 'bold', + }, + divider: { + height: 1, + backgroundColor: '#555', + marginTop: 4, + marginBottom: 8, + }, + col: { + gap: 8, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + btn: { + backgroundColor: '#6650A4', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + alignItems: 'center', + }, + btnDanger: { + backgroundColor: '#F2B8B5', + }, + btnText: { + color: '#fff', + fontWeight: '600', + }, + logContainer: { + height: 200, + borderTopWidth: 1, + borderTopColor: '#333', + backgroundColor: '#0B0B0B', + paddingHorizontal: 12, + paddingTop: 8, + }, + logTitle: { + color: '#888', + fontSize: 12, + fontWeight: '600', + textTransform: 'uppercase', + marginBottom: 4, + }, + log: { + flex: 1, + }, + logScroll: { + paddingBottom: 12, + }, + logEmpty: { + color: '#555', + fontSize: 13, + fontStyle: 'italic', + }, + logLine: { + color: '#A5D6A7', + fontSize: 12, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + paddingVertical: 1, + }, +}); From 1143993b0a6c4646893c039b2870c4e839a75dc2 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 24 Jun 2026 14:42:11 -0700 Subject: [PATCH 04/21] chore(example): gitignore RN example iOS/Ruby lock files Stop tracking the session-replay example's ios/Podfile.lock and Gemfile.lock, which are regenerated per-machine and churn on every pod/bundle install. The monorepo yarn.lock remains tracked for reproducible installs. Co-authored-by: Cursor --- .gitignore | 6 + .../example/Gemfile.lock | 197 -- .../example/ios/Podfile.lock | 2892 ----------------- 3 files changed, 6 insertions(+), 3089 deletions(-) delete mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile.lock delete mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/example/ios/Podfile.lock diff --git a/.gitignore b/.gitignore index fb5f955dba..3209752e12 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile.lock b/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile.lock deleted file mode 100644 index 90a574dbf8..0000000000 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile.lock +++ /dev/null @@ -1,197 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - CFPropertyList (3.0.8) - activesupport (7.2.3.1) - base64 - benchmark (>= 0.3) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - logger (>= 1.4.2) - minitest (>= 5.1, < 6) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - addressable (2.9.0) - public_suffix (>= 2.0.2, < 8.0) - algoliasearch (1.27.5) - httpclient (~> 2.8, >= 2.8.3) - json (>= 1.5.1) - atomos (0.1.3) - base64 (0.3.0) - benchmark (0.5.0) - bigdecimal (4.1.2) - claide (1.1.0) - cocoapods (1.15.2) - addressable (~> 2.8) - claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.15.2) - cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 2.1, < 3.0) - cocoapods-plugins (>= 1.0.0, < 2.0) - cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.6.0, < 2.0) - cocoapods-try (>= 1.1.0, < 2.0) - colored2 (~> 3.1) - escape (~> 0.0.4) - fourflusher (>= 2.3.0, < 3.0) - gh_inspector (~> 1.0) - molinillo (~> 0.8.0) - nap (~> 1.0) - ruby-macho (>= 2.3.0, < 3.0) - xcodeproj (>= 1.23.0, < 2.0) - cocoapods-core (1.15.2) - activesupport (>= 5.0, < 8) - addressable (~> 2.8) - algoliasearch (~> 1.0) - concurrent-ruby (~> 1.1) - fuzzy_match (~> 2.0.4) - nap (~> 1.0) - netrc (~> 0.11) - public_suffix (~> 4.0) - typhoeus (~> 1.0) - cocoapods-deintegrate (1.0.5) - cocoapods-downloader (2.1) - cocoapods-plugins (1.0.0) - nap - cocoapods-search (1.0.1) - cocoapods-trunk (1.6.0) - nap (>= 0.8, < 2.0) - netrc (~> 0.11) - cocoapods-try (1.2.0) - colored2 (3.1.2) - concurrent-ruby (1.3.3) - connection_pool (3.0.2) - drb (2.2.3) - escape (0.0.4) - ethon (0.18.0) - ffi (>= 1.15.0) - logger - ffi (1.17.4) - ffi (1.17.4-aarch64-linux-gnu) - ffi (1.17.4-aarch64-linux-musl) - ffi (1.17.4-arm-linux-gnu) - ffi (1.17.4-arm-linux-musl) - ffi (1.17.4-arm64-darwin) - ffi (1.17.4-x86-linux-gnu) - ffi (1.17.4-x86-linux-musl) - ffi (1.17.4-x86_64-darwin) - ffi (1.17.4-x86_64-linux-gnu) - ffi (1.17.4-x86_64-linux-musl) - fourflusher (2.3.1) - fuzzy_match (2.0.4) - gh_inspector (1.1.3) - httpclient (2.9.0) - mutex_m - i18n (1.14.8) - concurrent-ruby (~> 1.0) - json (2.19.4) - logger (1.7.0) - minitest (5.27.0) - molinillo (0.8.0) - mutex_m (0.3.0) - nanaimo (0.3.0) - nap (1.1.0) - netrc (0.11.0) - public_suffix (4.0.7) - rexml (3.4.4) - ruby-macho (2.5.1) - securerandom (0.4.1) - typhoeus (1.6.0) - ethon (>= 0.18.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - xcodeproj (1.25.1) - CFPropertyList (>= 2.3.3, < 4.0) - atomos (~> 0.1.3) - claide (>= 1.0.2, < 2.0) - colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (>= 3.3.6, < 4.0) - -PLATFORMS - aarch64-linux-gnu - aarch64-linux-musl - arm-linux-gnu - arm-linux-musl - arm64-darwin - ruby - x86-linux-gnu - x86-linux-musl - x86_64-darwin - x86_64-linux-gnu - x86_64-linux-musl - -DEPENDENCIES - activesupport (>= 6.1.7.5, != 7.1.0) - benchmark - bigdecimal - cocoapods (>= 1.13, != 1.15.1, != 1.15.0) - concurrent-ruby (< 1.3.4) - logger - mutex_m - xcodeproj (< 1.26.0) - -CHECKSUMS - CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 - activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34 - addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af - algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853 - atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f - base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c - bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd - claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e - cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a - cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136 - cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2 - cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1 - cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a - cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589 - cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31 - cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe - colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a - concurrent-ruby (1.3.3) sha256=4f9cd28965c4dcf83ffd3ea7304f9323277be8525819cb18a3b61edcb56a7c6a - connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 - escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169 - ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2 - ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf - ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df - ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39 - ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564 - ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95 - ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b - ffi (1.17.4-x86-linux-gnu) sha256=38e150df5f4ca555e25beca4090823ae09657bceded154e3c52f8631c1ed72cf - ffi (1.17.4-x86-linux-musl) sha256=fbeec0fc7c795bcf86f623bb18d31ea1820f7bd580e1703a3d3740d527437809 - ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 - ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d - ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e - fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9 - fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5 - gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 - httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 - i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 - json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac - logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 - molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d - mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 - nanaimo (0.3.0) sha256=aaaedc60497070b864a7e220f7c4b4cad3a0daddda2c30055ba8dae306342376 - nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576 - netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f - public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 - rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9 - securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d - tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - xcodeproj (1.25.1) sha256=9a2310dccf6d717076e86f602b17c640046b6f1dfe64480044596f6f2f13dc84 - -RUBY VERSION - ruby 3.2.2 - -BUNDLED WITH - 4.0.1 diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/ios/Podfile.lock b/sdk/@launchdarkly/react-native-ld-session-replay/example/ios/Podfile.lock deleted file mode 100644 index 7f79767e1a..0000000000 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/ios/Podfile.lock +++ /dev/null @@ -1,2892 +0,0 @@ -PODS: - - boost (1.84.0) - - DoubleConversion (1.1.6) - - fast_float (8.0.0) - - FBLazyVector (0.83.0) - - fmt (11.0.2) - - glog (0.3.5) - - hermes-engine (0.14.0): - - hermes-engine/Pre-built (= 0.14.0) - - hermes-engine/Pre-built (0.14.0) - - KSCrash (2.5.1): - - KSCrash/Installations (= 2.5.1) - - KSCrash/Core (2.5.1) - - KSCrash/DemangleFilter (2.5.1): - - KSCrash/Recording - - KSCrash/Filters (2.5.1): - - KSCrash/Recording - - KSCrash/RecordingCore - - KSCrash/ReportingCore - - KSCrash/Installations (2.5.1): - - KSCrash/DemangleFilter - - KSCrash/Filters - - KSCrash/Recording - - KSCrash/Sinks - - KSCrash/Recording (2.5.1): - - KSCrash/RecordingCore - - KSCrash/RecordingCore (2.5.1): - - KSCrash/Core - - KSCrash/ReportingCore (2.5.1): - - KSCrash/Core - - KSCrash/Sinks (2.5.1): - - KSCrash/Filters - - KSCrash/Recording - - LaunchDarkly (11.1.1): - - LaunchDarkly/Core (= 11.1.1) - - LaunchDarkly/Core (11.1.1): - - LDSwiftEventSource (= 3.3.0) - - LaunchDarklyObservability (0.24.0): - - LaunchDarklyObservability/LaunchDarklyObservability (= 0.24.0) - - LaunchDarklyObservability/Common (0.24.0): - - LaunchDarkly (~> 11.1.0) - - LaunchDarklyObservability/LaunchDarklyObservability (0.24.0): - - LaunchDarklyObservability/Common - - LaunchDarklyObservability/Misc - - LaunchDarklyObservability/OpenTelemetry - - LaunchDarklyObservability/OpenTelemetryProtocolExporterCommon - - LaunchDarklyObservability/SDKResourceExtension - - LaunchDarklyObservability/URLSessionInstrumentation - - LaunchDarklyObservability/Misc (0.24.0): - - KSCrash - - LaunchDarklyObservability/NetworkStatus (0.24.0): - - OpenTelemetry-Swift-Api (~> 2.3.0) - - LaunchDarklyObservability/OpenTelemetry (0.24.0): - - OpenTelemetry-Swift-Api (~> 2.3.0) - - OpenTelemetry-Swift-Sdk (~> 2.3.0) - - LaunchDarklyObservability/OpenTelemetryProtocolExporterCommon (0.24.0): - - OpenTelemetry-Swift-Sdk (~> 2.3.0) - - SwiftProtobuf - - LaunchDarklyObservability/SDKResourceExtension (0.24.0): - - LaunchDarklyObservability/OpenTelemetry - - LaunchDarklyObservability/URLSessionInstrumentation (0.24.0): - - LaunchDarklyObservability/NetworkStatus - - OpenTelemetry-Swift-Sdk (~> 2.3.0) - - LaunchDarklySessionReplay (0.26.2): - - LaunchDarklySessionReplay/LaunchDarklySessionReplay (= 0.26.2) - - LaunchDarklySessionReplay/LaunchDarklySessionReplay (0.26.2): - - LaunchDarklyObservability - - LaunchDarklySessionReplay/SessionReplayC - - LaunchDarklySessionReplay/SessionReplayC (0.26.2) - - LDSwiftEventSource (3.3.0) - - OpenTelemetry-Swift-Api (2.3.0) - - OpenTelemetry-Swift-Sdk (2.3.0): - - OpenTelemetry-Swift-Api (= 2.3.0) - - RCT-Folly (2024.11.18.00): - - boost - - DoubleConversion - - fast_float (= 8.0.0) - - fmt (= 11.0.2) - - glog - - RCT-Folly/Default (= 2024.11.18.00) - - RCT-Folly/Default (2024.11.18.00): - - boost - - DoubleConversion - - fast_float (= 8.0.0) - - fmt (= 11.0.2) - - glog - - RCT-Folly/Fabric (2024.11.18.00): - - boost - - DoubleConversion - - fast_float (= 8.0.0) - - fmt (= 11.0.2) - - glog - - RCTDeprecation (0.83.0) - - RCTRequired (0.83.0) - - RCTSwiftUI (0.83.0) - - RCTSwiftUIWrapper (0.83.0): - - RCTSwiftUI - - RCTTypeSafety (0.83.0): - - FBLazyVector (= 0.83.0) - - RCTRequired (= 0.83.0) - - React-Core (= 0.83.0) - - React (0.83.0): - - React-Core (= 0.83.0) - - React-Core/DevSupport (= 0.83.0) - - React-Core/RCTWebSocket (= 0.83.0) - - React-RCTActionSheet (= 0.83.0) - - React-RCTAnimation (= 0.83.0) - - React-RCTBlob (= 0.83.0) - - React-RCTImage (= 0.83.0) - - React-RCTLinking (= 0.83.0) - - React-RCTNetwork (= 0.83.0) - - React-RCTSettings (= 0.83.0) - - React-RCTText (= 0.83.0) - - React-RCTVibration (= 0.83.0) - - React-callinvoker (0.83.0) - - React-Core (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default (= 0.83.0) - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/CoreModulesHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/Default (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/DevSupport (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default (= 0.83.0) - - React-Core/RCTWebSocket (= 0.83.0) - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTActionSheetHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTAnimationHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTBlobHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTImageHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTLinkingHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTNetworkHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTSettingsHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTTextHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTVibrationHeaders (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-Core/RCTWebSocket (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTDeprecation - - React-Core/Default (= 0.83.0) - - React-cxxreact - - React-featureflags - - React-hermes - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsinspectorcdp - - React-jsitooling - - React-perflogger - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-CoreModules (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - RCTTypeSafety (= 0.83.0) - - React-Core/CoreModulesHeaders (= 0.83.0) - - React-debug - - React-jsi (= 0.83.0) - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-NativeModulesApple - - React-RCTBlob - - React-RCTFBReactNativeSpec - - React-RCTImage (= 0.83.0) - - React-runtimeexecutor - - React-utils - - ReactCommon - - SocketRocket - - React-cxxreact (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker (= 0.83.0) - - React-debug (= 0.83.0) - - React-jsi (= 0.83.0) - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - React-runtimeexecutor - - React-timing (= 0.83.0) - - React-utils - - SocketRocket - - React-debug (0.83.0) - - React-defaultsnativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-domnativemodule - - React-featureflags - - React-featureflagsnativemodule - - React-idlecallbacksnativemodule - - React-intersectionobservernativemodule - - React-jsi - - React-jsiexecutor - - React-microtasksnativemodule - - React-RCTFBReactNativeSpec - - React-webperformancenativemodule - - SocketRocket - - Yoga - - React-domnativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-Fabric - - React-Fabric/bridging - - React-FabricComponents - - React-graphics - - React-jsi - - React-jsiexecutor - - React-RCTFBReactNativeSpec - - React-runtimeexecutor - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-Fabric (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric/animated (= 0.83.0) - - React-Fabric/animationbackend (= 0.83.0) - - React-Fabric/animations (= 0.83.0) - - React-Fabric/attributedstring (= 0.83.0) - - React-Fabric/bridging (= 0.83.0) - - React-Fabric/componentregistry (= 0.83.0) - - React-Fabric/componentregistrynative (= 0.83.0) - - React-Fabric/components (= 0.83.0) - - React-Fabric/consistency (= 0.83.0) - - React-Fabric/core (= 0.83.0) - - React-Fabric/dom (= 0.83.0) - - React-Fabric/imagemanager (= 0.83.0) - - React-Fabric/leakchecker (= 0.83.0) - - React-Fabric/mounting (= 0.83.0) - - React-Fabric/observers (= 0.83.0) - - React-Fabric/scheduler (= 0.83.0) - - React-Fabric/telemetry (= 0.83.0) - - React-Fabric/templateprocessor (= 0.83.0) - - React-Fabric/uimanager (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/animated (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/animationbackend (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/animations (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/attributedstring (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/bridging (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/componentregistry (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/componentregistrynative (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/components (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric/components/legacyviewmanagerinterop (= 0.83.0) - - React-Fabric/components/root (= 0.83.0) - - React-Fabric/components/scrollview (= 0.83.0) - - React-Fabric/components/view (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/components/legacyviewmanagerinterop (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/components/root (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/components/scrollview (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/components/view (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-renderercss - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-Fabric/consistency (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/core (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/dom (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/imagemanager (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/leakchecker (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/mounting (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/observers (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric/observers/events (= 0.83.0) - - React-Fabric/observers/intersection (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/observers/events (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/observers/intersection (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/scheduler (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric/observers/events - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-performancecdpmetrics - - React-performancetimeline - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/telemetry (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/templateprocessor (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/uimanager (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric/uimanager/consistency (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererconsistency - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-Fabric/uimanager/consistency (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-rendererconsistency - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - React-FabricComponents (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-FabricComponents/components (= 0.83.0) - - React-FabricComponents/textlayoutmanager (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-FabricComponents/components/inputaccessory (= 0.83.0) - - React-FabricComponents/components/iostextinput (= 0.83.0) - - React-FabricComponents/components/modal (= 0.83.0) - - React-FabricComponents/components/rncore (= 0.83.0) - - React-FabricComponents/components/safeareaview (= 0.83.0) - - React-FabricComponents/components/scrollview (= 0.83.0) - - React-FabricComponents/components/switch (= 0.83.0) - - React-FabricComponents/components/text (= 0.83.0) - - React-FabricComponents/components/textinput (= 0.83.0) - - React-FabricComponents/components/unimplementedview (= 0.83.0) - - React-FabricComponents/components/virtualview (= 0.83.0) - - React-FabricComponents/components/virtualviewexperimental (= 0.83.0) - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/inputaccessory (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/iostextinput (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/modal (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/rncore (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/safeareaview (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/scrollview (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/switch (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/text (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/textinput (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/unimplementedview (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/virtualview (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/components/virtualviewexperimental (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricComponents/textlayoutmanager (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-cxxreact - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-logger - - React-RCTFBReactNativeSpec - - React-rendererdebug - - React-runtimescheduler - - React-utils - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-FabricImage (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired (= 0.83.0) - - RCTTypeSafety (= 0.83.0) - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsiexecutor (= 0.83.0) - - React-logger - - React-rendererdebug - - React-utils - - ReactCommon - - SocketRocket - - Yoga - - React-featureflags (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - SocketRocket - - React-featureflagsnativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-featureflags - - React-jsi - - React-jsiexecutor - - React-RCTFBReactNativeSpec - - ReactCommon/turbomodule/core - - SocketRocket - - React-graphics (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-jsi - - React-jsiexecutor - - React-utils - - SocketRocket - - React-hermes (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact (= 0.83.0) - - React-jsi - - React-jsiexecutor (= 0.83.0) - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-oscompat - - React-perflogger (= 0.83.0) - - React-runtimeexecutor - - SocketRocket - - React-idlecallbacksnativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-jsi - - React-jsiexecutor - - React-RCTFBReactNativeSpec - - React-runtimeexecutor - - React-runtimescheduler - - ReactCommon/turbomodule/core - - SocketRocket - - React-ImageManager (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-Core/Default - - React-debug - - React-Fabric - - React-graphics - - React-rendererdebug - - React-utils - - SocketRocket - - React-intersectionobservernativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact - - React-Fabric - - React-Fabric/bridging - - React-graphics - - React-jsi - - React-jsiexecutor - - React-RCTFBReactNativeSpec - - React-runtimeexecutor - - React-runtimescheduler - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - React-jserrorhandler (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact - - React-debug - - React-featureflags - - React-jsi - - ReactCommon/turbomodule/bridging - - SocketRocket - - React-jsi (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - SocketRocket - - React-jsiexecutor (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact - - React-debug - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-perflogger - - React-runtimeexecutor - - React-utils - - SocketRocket - - React-jsinspector (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-featureflags - - React-jsi - - React-jsinspectorcdp - - React-jsinspectornetwork - - React-jsinspectortracing - - React-oscompat - - React-perflogger (= 0.83.0) - - React-runtimeexecutor - - React-utils - - SocketRocket - - React-jsinspectorcdp (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - SocketRocket - - React-jsinspectornetwork (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-jsinspectorcdp - - SocketRocket - - React-jsinspectortracing (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-jsi - - React-jsinspectornetwork - - React-oscompat - - React-timing - - SocketRocket - - React-jsitooling (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact (= 0.83.0) - - React-debug - - React-jsi (= 0.83.0) - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-runtimeexecutor - - React-utils - - SocketRocket - - React-jsitracing (0.83.0): - - React-jsi - - React-logger (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - SocketRocket - - React-Mapbuffer (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-debug - - SocketRocket - - React-microtasksnativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-jsi - - React-jsiexecutor - - React-RCTFBReactNativeSpec - - ReactCommon/turbomodule/core - - SocketRocket - - React-NativeModulesApple (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker - - React-Core - - React-cxxreact - - React-debug - - React-featureflags - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-runtimeexecutor - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - SocketRocket - - React-networking (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-featureflags - - React-jsinspectornetwork - - React-jsinspectortracing - - React-performancetimeline - - React-timing - - SocketRocket - - React-oscompat (0.83.0) - - React-perflogger (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - SocketRocket - - React-performancecdpmetrics (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-jsi - - React-performancetimeline - - React-runtimeexecutor - - React-timing - - SocketRocket - - React-performancetimeline (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-featureflags - - React-jsinspectortracing - - React-perflogger - - React-timing - - SocketRocket - - React-RCTActionSheet (0.83.0): - - React-Core/RCTActionSheetHeaders (= 0.83.0) - - React-RCTAnimation (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - RCTTypeSafety - - React-Core/RCTAnimationHeaders - - React-featureflags - - React-jsi - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - ReactCommon - - SocketRocket - - React-RCTAppDelegate (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-CoreModules - - React-debug - - React-defaultsnativemodule - - React-Fabric - - React-featureflags - - React-graphics - - React-hermes - - React-jsitooling - - React-NativeModulesApple - - React-RCTFabric - - React-RCTFBReactNativeSpec - - React-RCTImage - - React-RCTNetwork - - React-RCTRuntime - - React-rendererdebug - - React-RuntimeApple - - React-RuntimeCore - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - ReactCommon - - SocketRocket - - React-RCTBlob (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-Core/RCTBlobHeaders - - React-Core/RCTWebSocket - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - React-RCTNetwork - - ReactCommon - - SocketRocket - - React-RCTFabric (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTSwiftUIWrapper - - React-Core - - React-debug - - React-Fabric - - React-FabricComponents - - React-FabricImage - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-networking - - React-performancecdpmetrics - - React-performancetimeline - - React-RCTAnimation - - React-RCTFBReactNativeSpec - - React-RCTImage - - React-RCTText - - React-rendererconsistency - - React-renderercss - - React-rendererdebug - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - Yoga - - React-RCTFBReactNativeSpec (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-jsi - - React-NativeModulesApple - - React-RCTFBReactNativeSpec/components (= 0.83.0) - - ReactCommon - - SocketRocket - - React-RCTFBReactNativeSpec/components (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-jsi - - React-NativeModulesApple - - React-rendererdebug - - React-utils - - ReactCommon - - SocketRocket - - Yoga - - React-RCTImage (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - RCTTypeSafety - - React-Core/RCTImageHeaders - - React-jsi - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - React-RCTNetwork - - ReactCommon - - SocketRocket - - React-RCTLinking (0.83.0): - - React-Core/RCTLinkingHeaders (= 0.83.0) - - React-jsi (= 0.83.0) - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - ReactCommon - - ReactCommon/turbomodule/core (= 0.83.0) - - React-RCTNetwork (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - RCTTypeSafety - - React-Core/RCTNetworkHeaders - - React-debug - - React-featureflags - - React-jsi - - React-jsinspectorcdp - - React-jsinspectornetwork - - React-NativeModulesApple - - React-networking - - React-RCTFBReactNativeSpec - - ReactCommon - - SocketRocket - - React-RCTRuntime (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-Core - - React-debug - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-jsitooling - - React-RuntimeApple - - React-RuntimeCore - - React-runtimeexecutor - - React-RuntimeHermes - - React-utils - - SocketRocket - - React-RCTSettings (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - RCTTypeSafety - - React-Core/RCTSettingsHeaders - - React-jsi - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - ReactCommon - - SocketRocket - - React-RCTText (0.83.0): - - React-Core/RCTTextHeaders (= 0.83.0) - - Yoga - - React-RCTVibration (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-Core/RCTVibrationHeaders - - React-jsi - - React-NativeModulesApple - - React-RCTFBReactNativeSpec - - ReactCommon - - SocketRocket - - React-rendererconsistency (0.83.0) - - React-renderercss (0.83.0): - - React-debug - - React-utils - - React-rendererdebug (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-debug - - SocketRocket - - React-RuntimeApple (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker - - React-Core/Default - - React-CoreModules - - React-cxxreact - - React-featureflags - - React-jserrorhandler - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsitooling - - React-Mapbuffer - - React-NativeModulesApple - - React-RCTFabric - - React-RCTFBReactNativeSpec - - React-RuntimeCore - - React-runtimeexecutor - - React-RuntimeHermes - - React-runtimescheduler - - React-utils - - SocketRocket - - React-RuntimeCore (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact - - React-Fabric - - React-featureflags - - React-jserrorhandler - - React-jsi - - React-jsiexecutor - - React-jsinspector - - React-jsitooling - - React-performancetimeline - - React-runtimeexecutor - - React-runtimescheduler - - React-utils - - SocketRocket - - React-runtimeexecutor (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - React-debug - - React-featureflags - - React-jsi (= 0.83.0) - - React-utils - - SocketRocket - - React-RuntimeHermes (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-featureflags - - React-hermes - - React-jsi - - React-jsinspector - - React-jsinspectorcdp - - React-jsinspectortracing - - React-jsitooling - - React-jsitracing - - React-RuntimeCore - - React-runtimeexecutor - - React-utils - - SocketRocket - - React-runtimescheduler (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker - - React-cxxreact - - React-debug - - React-featureflags - - React-jsi - - React-jsinspectortracing - - React-performancetimeline - - React-rendererconsistency - - React-rendererdebug - - React-runtimeexecutor - - React-timing - - React-utils - - SocketRocket - - React-timing (0.83.0): - - React-debug - - React-utils (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-debug - - React-jsi (= 0.83.0) - - SocketRocket - - React-webperformancenativemodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-cxxreact - - React-jsi - - React-jsiexecutor - - React-performancetimeline - - React-RCTFBReactNativeSpec - - React-runtimeexecutor - - ReactCommon/turbomodule/core - - SocketRocket - - ReactAppDependencyProvider (0.83.0): - - ReactCodegen - - ReactCodegen (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-debug - - React-Fabric - - React-FabricImage - - React-featureflags - - React-graphics - - React-jsi - - React-jsiexecutor - - React-NativeModulesApple - - React-RCTAppDelegate - - React-rendererdebug - - React-utils - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - SocketRocket - - ReactCommon (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - RCT-Folly - - RCT-Folly/Fabric - - ReactCommon/turbomodule (= 0.83.0) - - SocketRocket - - ReactCommon/turbomodule (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker (= 0.83.0) - - React-cxxreact (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - ReactCommon/turbomodule/bridging (= 0.83.0) - - ReactCommon/turbomodule/core (= 0.83.0) - - SocketRocket - - ReactCommon/turbomodule/bridging (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker (= 0.83.0) - - React-cxxreact (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - SocketRocket - - ReactCommon/turbomodule/core (0.83.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - RCT-Folly - - RCT-Folly/Fabric - - React-callinvoker (= 0.83.0) - - React-cxxreact (= 0.83.0) - - React-debug (= 0.83.0) - - React-featureflags (= 0.83.0) - - React-jsi (= 0.83.0) - - React-logger (= 0.83.0) - - React-perflogger (= 0.83.0) - - React-utils (= 0.83.0) - - SocketRocket - - SessionReplayReactNative (0.5.0): - - boost - - DoubleConversion - - fast_float - - fmt - - glog - - hermes-engine - - LaunchDarklySessionReplay (~> 0.26.2) - - RCT-Folly - - RCT-Folly/Fabric - - RCTRequired - - RCTTypeSafety - - React-Core - - React-debug - - React-Fabric - - React-featureflags - - React-graphics - - React-ImageManager - - React-jsi - - React-NativeModulesApple - - React-RCTFabric - - React-renderercss - - React-rendererdebug - - React-utils - - ReactCodegen - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - SocketRocket - - Yoga - - SocketRocket (0.7.1) - - SwiftProtobuf (1.36.1) - - Yoga (0.0.0) - -DEPENDENCIES: - - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) - - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) - - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../node_modules/react-native/`) - - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../node_modules/react-native/`) - - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) - - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) - - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) - - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) - - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) - - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) - - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) - - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../node_modules/react-native/React`) - - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) - - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) - - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) - - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) - - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) - - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) - - ReactCodegen (from `build/generated/ios/ReactCodegen`) - - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - - SessionReplayReactNative (from `../..`) - - SocketRocket (~> 0.7.1) - - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) - -SPEC REPOS: - trunk: - - KSCrash - - LaunchDarkly - - LaunchDarklyObservability - - LaunchDarklySessionReplay - - LDSwiftEventSource - - OpenTelemetry-Swift-Api - - OpenTelemetry-Swift-Sdk - - SocketRocket - - SwiftProtobuf - -EXTERNAL SOURCES: - boost: - :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" - DoubleConversion: - :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" - fast_float: - :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec" - FBLazyVector: - :path: "../node_modules/react-native/Libraries/FBLazyVector" - fmt: - :podspec: "../node_modules/react-native/third-party-podspecs/fmt.podspec" - glog: - :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" - hermes-engine: - :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" - :tag: hermes-v0.14.0 - RCT-Folly: - :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" - RCTDeprecation: - :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" - RCTRequired: - :path: "../node_modules/react-native/Libraries/Required" - RCTSwiftUI: - :path: "../node_modules/react-native/ReactApple/RCTSwiftUI" - RCTSwiftUIWrapper: - :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" - RCTTypeSafety: - :path: "../node_modules/react-native/Libraries/TypeSafety" - React: - :path: "../node_modules/react-native/" - React-callinvoker: - :path: "../node_modules/react-native/ReactCommon/callinvoker" - React-Core: - :path: "../node_modules/react-native/" - React-CoreModules: - :path: "../node_modules/react-native/React/CoreModules" - React-cxxreact: - :path: "../node_modules/react-native/ReactCommon/cxxreact" - React-debug: - :path: "../node_modules/react-native/ReactCommon/react/debug" - React-defaultsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" - React-domnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" - React-Fabric: - :path: "../node_modules/react-native/ReactCommon" - React-FabricComponents: - :path: "../node_modules/react-native/ReactCommon" - React-FabricImage: - :path: "../node_modules/react-native/ReactCommon" - React-featureflags: - :path: "../node_modules/react-native/ReactCommon/react/featureflags" - React-featureflagsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" - React-graphics: - :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" - React-hermes: - :path: "../node_modules/react-native/ReactCommon/hermes" - React-idlecallbacksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" - React-ImageManager: - :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" - React-intersectionobservernativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" - React-jserrorhandler: - :path: "../node_modules/react-native/ReactCommon/jserrorhandler" - React-jsi: - :path: "../node_modules/react-native/ReactCommon/jsi" - React-jsiexecutor: - :path: "../node_modules/react-native/ReactCommon/jsiexecutor" - React-jsinspector: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" - React-jsinspectorcdp: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" - React-jsinspectornetwork: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" - React-jsinspectortracing: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" - React-jsitooling: - :path: "../node_modules/react-native/ReactCommon/jsitooling" - React-jsitracing: - :path: "../node_modules/react-native/ReactCommon/hermes/executor/" - React-logger: - :path: "../node_modules/react-native/ReactCommon/logger" - React-Mapbuffer: - :path: "../node_modules/react-native/ReactCommon" - React-microtasksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" - React-NativeModulesApple: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" - React-networking: - :path: "../node_modules/react-native/ReactCommon/react/networking" - React-oscompat: - :path: "../node_modules/react-native/ReactCommon/oscompat" - React-perflogger: - :path: "../node_modules/react-native/ReactCommon/reactperflogger" - React-performancecdpmetrics: - :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" - React-performancetimeline: - :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" - React-RCTActionSheet: - :path: "../node_modules/react-native/Libraries/ActionSheetIOS" - React-RCTAnimation: - :path: "../node_modules/react-native/Libraries/NativeAnimation" - React-RCTAppDelegate: - :path: "../node_modules/react-native/Libraries/AppDelegate" - React-RCTBlob: - :path: "../node_modules/react-native/Libraries/Blob" - React-RCTFabric: - :path: "../node_modules/react-native/React" - React-RCTFBReactNativeSpec: - :path: "../node_modules/react-native/React" - React-RCTImage: - :path: "../node_modules/react-native/Libraries/Image" - React-RCTLinking: - :path: "../node_modules/react-native/Libraries/LinkingIOS" - React-RCTNetwork: - :path: "../node_modules/react-native/Libraries/Network" - React-RCTRuntime: - :path: "../node_modules/react-native/React/Runtime" - React-RCTSettings: - :path: "../node_modules/react-native/Libraries/Settings" - React-RCTText: - :path: "../node_modules/react-native/Libraries/Text" - React-RCTVibration: - :path: "../node_modules/react-native/Libraries/Vibration" - React-rendererconsistency: - :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" - React-renderercss: - :path: "../node_modules/react-native/ReactCommon/react/renderer/css" - React-rendererdebug: - :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" - React-RuntimeApple: - :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" - React-RuntimeCore: - :path: "../node_modules/react-native/ReactCommon/react/runtime" - React-runtimeexecutor: - :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" - React-RuntimeHermes: - :path: "../node_modules/react-native/ReactCommon/react/runtime" - React-runtimescheduler: - :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" - React-timing: - :path: "../node_modules/react-native/ReactCommon/react/timing" - React-utils: - :path: "../node_modules/react-native/ReactCommon/react/utils" - React-webperformancenativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" - ReactAppDependencyProvider: - :path: build/generated/ios/ReactAppDependencyProvider - ReactCodegen: - :path: build/generated/ios/ReactCodegen - ReactCommon: - :path: "../node_modules/react-native/ReactCommon" - SessionReplayReactNative: - :path: "../.." - Yoga: - :path: "../node_modules/react-native/ReactCommon/yoga" - -SPEC CHECKSUMS: - boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb - fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 - FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd - glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - hermes-engine: 30639e02d0cb4542ae6f523e1bd4d7f7064f6c62 - KSCrash: 8c4464fd5da7de520f2ce4a00fdf63f169a80f18 - LaunchDarkly: 489abfe0f131952adeac976077f953d188ee4731 - LaunchDarklyObservability: 7806e37693d38674b2b1f78187af2e145b2173e5 - LaunchDarklySessionReplay: a5b9d7698455dab185963a1644f5d794c662579f - LDSwiftEventSource: b0826ca826ca890609a9833a05cae1f357fd3b99 - OpenTelemetry-Swift-Api: 3d77582ab6837a63b65bf7d2eacc57d8f2595edd - OpenTelemetry-Swift-Sdk: 69d60f0242e830366e359481edd575d6776eb983 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 - RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 - RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 - RCTSwiftUI: 5928f7ca7e9e2f1a82d85d4c79ea3065137ad81c - RCTSwiftUIWrapper: 8ff2f9da84b47db66d11ece1589d8e5515c0ab8b - RCTTypeSafety: 6359ff3fcbe18c52059f4d4ce301e47f9da5f0d5 - React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed - React-callinvoker: 032b6d1d03654b9fb7de9e2b3b978d3cb1a893ad - React-Core: 418c9278f8a071b44a88a87be9a4943234cc2e77 - React-CoreModules: 925b8cb677649f967f6000f9b1ef74dc4ff60c30 - React-cxxreact: 21f6f0cb2a7d26fbed4d09e04482e5c75662beaf - React-debug: 8fc21f2fecd3d6244e988dc55d60cb117d122588 - React-defaultsnativemodule: 05c6115a2d3a7f4a2cc3f96022261570700dbfa5 - React-domnativemodule: f19d7fd59facf19a4e6cb75bf48357c329acaea7 - React-Fabric: 94acdbc0b889bdcec2d5b1a90ae48f1032c5a5a1 - React-FabricComponents: 9754fb783979b88fb82ed3d0c50ae5f5d775a86f - React-FabricImage: d8f5bcb5006eafc0e2262c11bf4dedaa610fd66c - React-featureflags: 8bd4abaf8adf3cf5cc115f128e8761fd3d95b848 - React-featureflagsnativemodule: 0062ca1dc92cb5aae22df8aed4e8f261759cb3bd - React-graphics: 318048b8f98e040c093adcb77ffeb46d78961c30 - React-hermes: 05ca52f53557a31b8ef8bac8f94c3f9db1ff00ed - React-idlecallbacksnativemodule: d3c5ba0150555ce9b7db85008aeb170a02bbf2d8 - React-ImageManager: 225b19fcb16fd353851d664c344025a6d4d79870 - React-intersectionobservernativemodule: d490ebd28572754dfdad4a8d0771573345b1ec92 - React-jserrorhandler: caafb9c1d42c24422829e71e8178de3dd1c7ea12 - React-jsi: 749de748ad3b760011255326c63bf7b7dd6f8f9d - React-jsiexecutor: 02a5ee45bffcae98197eaa253fbf13b65c95073d - React-jsinspector: 4a031b0605009d4bcd079c99df85eb55d142cd12 - React-jsinspectorcdp: 6d25166ec876053b7b6e290eb57f41a9f9496846 - React-jsinspectornetwork: 5c481d208eade7a338f545b2645a2cf134fdf265 - React-jsinspectortracing: b4d2404ecd64a0dd65e2746d9867fbc3a7cd0927 - React-jsitooling: e0d93e78a5a231e4089459ddbed8d4844be9e238 - React-jsitracing: f3c4aae144b86799e9e23eb5ef16bae6b474d4e2 - React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc - React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 - React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee - React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 - React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 - React-perflogger: c174462de00c0b7d768f0b2d61b8e2240717a667 - React-performancecdpmetrics: 2607a034407d55049f1820b7ec86db1efd3d22e1 - React-performancetimeline: 6ebdcdf745dbe372508ad7164e732362e7eeae6f - React-RCTActionSheet: 175c74d343e92793d3187b3a819d565f534e0b1d - React-RCTAnimation: d67919cddb7da39c949b8010b4fd4ea39815fe4e - React-RCTAppDelegate: 5f7b1e4b7ee5a44faf5f9518a7d3cabafb801adf - React-RCTBlob: 7ceb93e0918511163f036cfd295973f132a2bc57 - React-RCTFabric: f2250d34e1143c659b845af7e369b3f8f015950c - React-RCTFBReactNativeSpec: b0fc0c9c8adaf8b9183f9e9fb5455ca5deedc7a0 - React-RCTImage: d6297035168312fc3089f8ca0ee7a75216f21715 - React-RCTLinking: 619a2553c4ef83acaccfb551ada1b7d45cf1cce3 - React-RCTNetwork: 7df41788a194dc5b628f58db6a765224b6b37eac - React-RCTRuntime: f75ec08d991c611f1d74154dfeb852e30b1825dd - React-RCTSettings: fa7882ce3d73f1e3482fe05f9cb3167a35a60869 - React-RCTText: 4d659598d9b7730343d465c43d97b3f4aad13938 - React-RCTVibration: 968c3184bfe5005bedd86c913a3b52438222e3a4 - React-rendererconsistency: 1204c62facf6168b69bc5022e0020f19c92f138e - React-renderercss: 36c02a3c55402fdb06226c2ef04d82fc06c4e2fc - React-rendererdebug: 11b54233498d961d939d2f2ec6c640d44efa3c12 - React-RuntimeApple: 5287d92680f4b08c8e882afe9791a41eab69d4a7 - React-RuntimeCore: 402b658d8e9cefb44824624e39a0804f2237e205 - React-runtimeexecutor: a1ce75c4e153ede11be957ef31bb72eef9cc4daf - React-RuntimeHermes: c987b19a1284c685062d3eaad79fd9300a3aa82f - React-runtimescheduler: a12722da46f562626f5897edf9b8fa02219de065 - React-timing: a453a65192dbe400d61d299024e95a302e726661 - React-utils: 43479e74f806f6633ee04c212c48811530041170 - React-webperformancenativemodule: bd1ad71ea9e217e55f66233e99d02581ee3d5cb7 - ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 - ReactCodegen: 11c08ff43a62009d48c71de000352e4515918801 - ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - SessionReplayReactNative: 60664a81e02ef6a64da725c84fe6e709c6f0cd5d - SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - SwiftProtobuf: 9e106a71456f4d3f6a3b0c8fd87ef0be085efc38 - Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e - -PODFILE CHECKSUM: 53fee6f649d87604b0c5ad827154b6c454d1a29a - -COCOAPODS: 1.16.2 From 3fc0099554dde43c069c9f8ae4fd91c002f9fcfc Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Wed, 24 Jun 2026 17:32:21 -0700 Subject: [PATCH 05/21] fix(observability-android): report service.name and service.version as resource attributes buildObservabilityResource strips the default service.name from the OTel resource but never re-adds one from options, so exported spans, logs, and metrics had an empty service.name (the value only flowed to Session Replay). Populate service.name / service.version from ObservabilityOptions.serviceName and serviceVersion in the shared resource builder so both init paths (LDClient plugin and standalone LDObserve.init) emit a populated service identity. Co-authored-by: Cursor --- .../client/ObservabilityResource.kt | 7 ++- .../client/ObservabilityResourceTest.kt | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityResourceTest.kt diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt index ae936964b0..bbb52a57e8 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityResource.kt @@ -34,7 +34,9 @@ internal val DEFAULT_DISTRO_ATTRIBUTES: Map = mapOf( * 2. `highlight.project_id` = [sdkKey]. * 3. [distroAttributes] (defaults to [DEFAULT_DISTRO_ATTRIBUTES] — `telemetry.distro.{name,version}`). * 4. Caller-supplied [ObservabilityOptions.resourceAttributes]. - * 5. `launchdarkly.application.id`, `launchdarkly.application.version`, `launchdarkly.sdk.version` + * 5. `service.name` = [ObservabilityOptions.serviceName] and `service.version` = + * [ObservabilityOptions.serviceVersion], so the configured service identity always wins. + * 6. `launchdarkly.application.id`, `launchdarkly.application.version`, `launchdarkly.sdk.version` * when provided (LDClient plugin path only). * * The metadata-derived attributes (5) come *after* the user's [ObservabilityOptions.resourceAttributes] @@ -76,6 +78,9 @@ internal fun buildObservabilityResource( builder.putAll(options.resourceAttributes) + builder.put("service.name", options.serviceName) + builder.put("service.version", options.serviceVersion) + applicationId?.let { builder.put("launchdarkly.application.id", it) } applicationVersion?.let { builder.put("launchdarkly.application.version", it) } sdkVersion?.let { builder.put("launchdarkly.sdk.version", it) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityResourceTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityResourceTest.kt new file mode 100644 index 0000000000..60454cf6c3 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/client/ObservabilityResourceTest.kt @@ -0,0 +1,43 @@ +package com.launchdarkly.observability.client + +import com.launchdarkly.observability.api.ObservabilityOptions +import io.opentelemetry.api.common.AttributeKey +import io.opentelemetry.api.common.Attributes +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Unit tests for [buildObservabilityResource], covering the `service.name` / `service.version` + * resource attributes that backends use to identify the emitting service. + */ +class ObservabilityResourceTest { + private val serviceName = AttributeKey.stringKey("service.name") + private val serviceVersion = AttributeKey.stringKey("service.version") + + @Test + fun `service name and version from options are written to the resource`() { + val resource = buildObservabilityResource( + sdkKey = "test-key", + options = ObservabilityOptions( + serviceName = "my-service", + serviceVersion = "1.2.3", + ), + ) + + assertEquals("my-service", resource.attributes.get(serviceName)) + assertEquals("1.2.3", resource.attributes.get(serviceVersion)) + } + + @Test + fun `options service name overrides one supplied via resourceAttributes`() { + val resource = buildObservabilityResource( + sdkKey = "test-key", + options = ObservabilityOptions( + serviceName = "configured-service", + resourceAttributes = Attributes.of(serviceName, "attr-service"), + ), + ) + + assertEquals("configured-service", resource.attributes.get(serviceName)) + } +} From 9086597b67653f24fdd413b07cd64b9397504072 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 10:23:29 -0700 Subject: [PATCH 06/21] chore(session-replay-rn): bump launchdarkly-android-client-sdk to 0.46.1 Co-authored-by: Cursor --- .../react-native-ld-session-replay/android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle index be177b29d1..4c6406794d 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle @@ -84,7 +84,7 @@ dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.launchdarkly:launchdarkly-observability-android:0.45.0" - implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.12.2" + implementation "com.launchdarkly:launchdarkly-android-client-sdk:0.46.1" // compileOnly: OTel Attributes appears in ObservabilityOptions parameter types; provided at // runtime transitively through launchdarkly-observability-android. compileOnly "io.opentelemetry:opentelemetry-api:1.51.0" From c907c459b402b8a1b92ee8659b5270c372abd479 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 10:25:08 -0700 Subject: [PATCH 07/21] RN work --- .../androidobservability/BaseApplication.kt | 2 + .../lib/build.gradle.kts | 4 +- .../observability/client/LDSessionManager.kt | 149 ++++++ .../client/ObservabilityService.kt | 69 ++- .../observability/plugin/Observability.kt | 8 +- .../observability/sdk/LDObserve.kt | 9 +- .../android/LDRumSessionManagerAccessor.kt | 17 + .../observability-react-native/README.md | 2 +- .../{distributed-tracing.md => tracing.md} | 193 ++++++- .../src/api/Observe.ts | 25 + .../src/api/SpanScope.ts | 67 +++ .../src/api/index.ts | 1 + .../src/constants/featureFlags.ts | 8 + .../src/plugin/observability.ts | 39 ++ .../src/sdk/LDObserve.ts | 14 + .../src/sdk/withSpan.ts | 105 ++++ .../SessionReplayReactNative.podspec | 2 +- .../SessionReplayClientAdapter.kt | 38 +- .../example/Gemfile | 3 + .../example/metro.config.js | 56 +- .../example/src/ApiScreen.tsx | 490 ++++++++++++++++++ .../example/src/App.tsx | 25 +- .../example/src/TracingScreen.tsx | 106 ++-- .../ios/SessionReplayClientAdapter.swift | 56 +- .../src/NativeSessionReplayReactNative.ts | 23 + .../src/index.tsx | 27 +- yarn.lock | 2 + 27 files changed, 1411 insertions(+), 129 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt rename sdk/@launchdarkly/observability-react-native/guides/{distributed-tracing.md => tracing.md} (65%) create mode 100644 sdk/@launchdarkly/observability-react-native/src/api/SpanScope.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/sdk/withSpan.ts create mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt index 46d4ffa4fe..7aef09840d 100644 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt +++ b/e2e/android/app/src/compose/java/com/example/androidobservability/BaseApplication.kt @@ -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() { @@ -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( diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 9f31fa2fd1..a5a7b63d1e 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -92,7 +92,9 @@ dependencies { // OTEL Android Instrumentations implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha") - implementation("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") + // NOTE: the `activity` instrumentation is intentionally NOT depended on. It is superseded by + // LaunchDarkly's own app/screen lifecycle spans and would otherwise double-report; we also + // defensively suppress it by name (see ObservabilityService.createOtelRumConfig). // Use JUnit Jupiter for testing. // Testing exporters for telemetry inspection diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt new file mode 100644 index 0000000000..38a378d862 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt @@ -0,0 +1,149 @@ +package com.launchdarkly.observability.client + +import io.opentelemetry.android.session.Session +import io.opentelemetry.android.session.SessionIdGenerator +import io.opentelemetry.android.session.SessionManager +import io.opentelemetry.android.session.SessionObserver +import io.opentelemetry.sdk.common.Clock +import java.util.Collections.synchronizedList +import kotlin.time.Duration + +/** + * LaunchDarkly's own [SessionManager], used in place of OpenTelemetry Android's default so we can + * **seed the initial session id** ([initialSessionId]). This lets the native observability instance + * adopt a session id created elsewhere (e.g. by the JavaScript SDK on the same device), so that + * spans, logs, metrics, and Session Replay all report the same `session.id`. + * + * Injected into [io.opentelemetry.android.OpenTelemetryRumBuilder] via + * [io.opentelemetry.android.LDRumSessionManagerAccessor] so it backs OpenTelemetry Android's own + * `session.id` span/log appenders as well — making it the single source of session identity. + * + * When [initialSessionId] is supplied the session is treated as *custom*: the caller owns the + * session lifecycle, so automatic rotation is disabled and the seeded id is used for the lifetime + * of this manager (mirrors iOS's `isCustomSession`). + * + * Otherwise rotation mirrors OpenTelemetry Android's default `SessionManagerImpl` / + * `SessionIdTimeoutHandler`: + * - a foreground session never times out; + * - a background gap of at least [backgroundInactivityTimeout] rotates the session on next use; + * - a session older than [maxLifetime] rotates regardless of foreground/background. + * + * Foreground/background transitions are fed in via [onApplicationForegrounded] / + * [onApplicationBackgrounded] (wired from the service's app-lifecycle tracker). + * + * @param initialSessionId Optional session id to start with. When non-blank the session is custom + * (never auto-rotated). When null or blank, an id is generated lazily on first use and rotated + * automatically (matching the default manager). + * @param backgroundInactivityTimeout Background inactivity after which the session rotates. + * @param maxLifetime Absolute maximum session lifetime before rotation. + * @param clock Time source; defaults to the OpenTelemetry default clock. + * @param idGenerator Generator for new session ids; defaults to OpenTelemetry's. + */ +internal class LDSessionManager( + initialSessionId: String? = null, + private val backgroundInactivityTimeout: Duration, + private val maxLifetime: Duration, + private val clock: Clock = Clock.getDefault(), + private val idGenerator: SessionIdGenerator = SessionIdGenerator.DEFAULT, +) : SessionManager { + + private val lock = Any() + private val observers = synchronizedList(ArrayList()) + + /** + * Whether a session id was supplied externally. When true, the caller owns the session + * lifecycle, so automatic rotation (background-inactivity timeout and max-lifetime) is disabled + * and the seeded id is used for the lifetime of this manager. Mirrors iOS's `isCustomSession`. + */ + private val isCustomSession: Boolean = !initialSessionId.isNullOrBlank() + + // Guarded by [lock]. + private var session: Session = + if (isCustomSession) { + Session.DefaultSession(initialSessionId!!, clock.now()) + } else { + // Empty id + (-1) timestamp forces generation on first getSessionId(), exactly like + // the default manager's initial Session.NONE. + Session.NONE + } + + // Timeout bookkeeping, mirroring SessionIdTimeoutHandler. + @Volatile + private var timeoutStartNanos: Long = clock.nanoTime() + + @Volatile + private var state: State = State.FOREGROUND + + override fun addObserver(observer: SessionObserver) { + observers.add(observer) + } + + override fun getSessionId(): String { + val newSession: Session + val previousSession: Session + val rotated: Boolean + + synchronized(lock) { + var candidate = session + // An externally supplied session id is never rotated; the caller owns its lifecycle. + if (!isCustomSession && (sessionHasExpired() || hasTimedOut())) { + candidate = Session.DefaultSession(idGenerator.generateSessionId(), clock.now()) + } + // Bump the inactivity timer after deciding, before notifying (a new span may be created). + bump() + rotated = candidate !== session + previousSession = session + if (rotated) { + session = candidate + } + newSession = session + } + + if (rotated) { + // Notify outside the lock; observers may create spans which call back into getSessionId(). + observers.forEach { observer -> + observer.onSessionEnded(previousSession) + observer.onSessionStarted(newSession, previousSession) + } + } + return newSession.getId() + } + + /** Marks the app as transitioning to the foreground; the next event settles it to foreground. */ + fun onApplicationForegrounded() { + state = State.TRANSITIONING_TO_FOREGROUND + } + + /** Marks the app as backgrounded, after which the inactivity timeout can rotate the session. */ + fun onApplicationBackgrounded() { + state = State.BACKGROUND + } + + private fun sessionHasExpired(): Boolean { + val elapsed = clock.now() - session.getStartTimestamp() + return elapsed >= maxLifetime.inWholeNanoseconds + } + + private fun hasTimedOut(): Boolean { + // The session never times out while the app is in the foreground. + if (state == State.FOREGROUND) return false + val elapsed = clock.nanoTime() - timeoutStartNanos + return elapsed >= backgroundInactivityTimeout.inWholeNanoseconds + } + + private fun bump() { + timeoutStartNanos = clock.nanoTime() + // The first event after returning to the foreground settles the transitional state. + if (state == State.TRANSITIONING_TO_FOREGROUND) { + state = State.FOREGROUND + } + } + + private enum class State { + FOREGROUND, + BACKGROUND, + + /** Temporary state for the first event after the app is brought back to the foreground. */ + TRANSITIONING_TO_FOREGROUND, + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index dcf4bb3cc2..0146368f9f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -3,6 +3,8 @@ package com.launchdarkly.observability.client import android.app.Application import android.view.MotionEvent import android.view.ViewConfiguration +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.bridge.emitLog @@ -33,13 +35,11 @@ import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.traces.EventSpanProcessor import com.launchdarkly.observability.traces.OtlpTraceExporter import com.launchdarkly.observability.util.requireMainThread +import io.opentelemetry.android.LDRumSessionManagerAccessor import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.OpenTelemetryRumBuilder import io.opentelemetry.android.config.OtelRumConfig -import io.opentelemetry.android.instrumentation.AndroidInstrumentation -import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.session.Session -import io.opentelemetry.android.session.SessionConfig import io.opentelemetry.android.session.SessionManager import io.opentelemetry.android.session.SessionObserver import io.opentelemetry.api.common.AttributeKey @@ -73,6 +73,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.hours /** * The [ObservabilityService] can be used for recording observability data such as @@ -98,9 +99,22 @@ class ObservabilityService( private val resources: Resource, private val logger: ObserveLogger, private val observabilityOptions: ObservabilityOptions, + // Optional session id to adopt (e.g. forwarded from another LaunchDarkly SDK on the device) so + // all signals share one `session.id`. Null lets the manager generate its own. + private val customSessionId: String? = null, ) : Observe, TrackEmitting { private val otelRUM: OpenTelemetryRum - var sessionManager: SessionManager? = null + + // LaunchDarkly's own session manager. Owns session identity for all signals (it also backs the + // RUM SDK's `session.id` appenders via LDRumSessionManagerAccessor) and can be seeded with + // [customSessionId]. Exposed through [sessionManager] for Session Replay. + private val ldSessionManager = LDSessionManager( + initialSessionId = customSessionId, + backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout, + maxLifetime = 4.hours, + ) + + var sessionManager: SessionManager? = ldSessionManager private set private var otelMeter: Meter private var otelLogger: Logger @@ -209,8 +223,6 @@ class ObservabilityService( registerOtlpExporters() val otelRumConfig = createOtelRumConfig() - var capturedSessionManager: SessionManager? = null - val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ -> return@addLoggerProviderCustomizer configureLoggerProvider(sdkLoggerProviderBuilder) @@ -222,22 +234,15 @@ class ObservabilityService( return@addMeterProviderCustomizer configureMeterProvider(sdkMeterProviderBuilder) } - rumBuilder.addInstrumentation(object : AndroidInstrumentation { - override val name = "ld-session-manager-bridge" - override fun install(ctx: InstallationContext) { - capturedSessionManager = ctx.sessionManager - } - }) + // Use our own session manager (instead of the RUM SDK's default) so we can seed the session + // id and keep a single source of session identity across spans, logs, metrics, and replay. + LDRumSessionManagerAccessor.setSessionManager(rumBuilder, ldSessionManager) if (observabilityOptions.instrumentations.launchTime) { addLaunchTimeInstrumentation(rumBuilder) } otelRUM = rumBuilder.build() - sessionManager = capturedSessionManager - if (sessionManager == null) { - logger.warn("SessionManager was not captured during OpenTelemetryRum.build(); session-dependent features will be unavailable.") - } // A new session (e.g. after a background timeout) must start with a fresh navigation // history, otherwise the first screen_view/Navigate of the new session would resolve @@ -311,6 +316,16 @@ class ObservabilityService( // available; the span itself is gated by analytics.appLifecycle inside the handler. appLifecycleTracker.start() + // Prime the session manager with the actual process state. [appLifecycleTracker] only + // reports genuine foreground/background transitions (it suppresses the initial replay and + // never emits a catch-up background), so if the SDK initializes while the app is already + // backgrounded the manager would otherwise stay FOREGROUND and skip background-inactivity + // rotation until the next foreground/background cycle. A genuine onStart will later settle + // it back to foreground if the app comes forward. + if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + ldSessionManager.onApplicationBackgrounded() + } + // App-launch detection runs unconditionally so the Session Replay `Launch` breadcrumb is // always available; the `app_launch` span is gated by analytics.appLaunch inside the handler. appLaunchTracker.start() @@ -410,20 +425,22 @@ class ObservabilityService( } private fun createOtelRumConfig(): OtelRumConfig { + // Session lifetime/rotation is owned by [ldSessionManager] (injected via + // [LDRumSessionManagerAccessor]), so no SessionConfig is applied here. val config = OtelRumConfig() - .setSessionConfig(SessionConfig(backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout)) if (!observabilityOptions.instrumentations.crashReporting) { // Disables [io.opentelemetry.android.instrumentation.crash.CrashReporterInstrumentation.java] config.suppressInstrumentation("crash") } - // Always disable the OpenTelemetry Android activity instrumentation + // Defensively disable the OpenTelemetry Android activity instrumentation // ([io.opentelemetry.android.instrumentation.activity.ActivityLifecycleInstrumentation]). - // It emits an `AppStart` span plus per-activity lifecycle spans (`Created`, `Resumed`, - // `Paused`, `Stopped`, `Destroyed`, `Restarted`). These are now superseded by LaunchDarkly's - // own `app_launch`, `app_foreground`/`app_background`, and `screen_view` spans, so leaving - // it on would double-report the same app/screen lifecycle. + // We no longer depend on that artifact (see lib/build.gradle.kts), so it is normally not on + // the classpath; this suppression guards against it being reintroduced transitively by a + // host. It emits an `AppStart` span plus per-activity lifecycle spans, which are superseded + // by LaunchDarkly's own `app_launch`, `app_foreground`/`app_background`, and `screen_view` + // spans, so leaving it on would double-report the same app/screen lifecycle. config.suppressInstrumentation("activity") return config @@ -772,6 +789,12 @@ class ObservabilityService( * navigation/track emitters. */ private fun handleAppLifecycleSignal(signal: AppLifecycleSignal) { + // Drive the session manager's background-inactivity timeout from the same lifecycle source. + when (signal.kind) { + AppLifecycleSignal.Kind.FOREGROUND -> ldSessionManager.onApplicationForegrounded() + AppLifecycleSignal.Kind.BACKGROUND -> ldSessionManager.onApplicationBackgrounded() + } + // Broadcast so Session Replay can emit a breadcrumb independent of the span flags below. _appLifecycleFlow.tryEmit(signal) @@ -912,7 +935,7 @@ class ObservabilityService( } } - private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, otelRUM.rumSessionId).build() + private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, ldSessionManager.getSessionId()).build() companion object { private const val METRICS_PATH = "/v1/metrics" diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt index eb1ce36e80..260a329960 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -47,11 +47,15 @@ import java.util.Collections * @param application The application instance. * @param options The options for the plugin. * @param mobileKey The primary mobile key used in LDConfig. + * @param customSessionId Optional session id to adopt instead of generating one. Lets the native + * instance share a single `session.id` with another LaunchDarkly SDK on the device (e.g. the + * JavaScript SDK in a React Native app). When null, a session id is generated automatically. */ class Observability( private val application: Application, private val mobileKey: String, - private val options: ObservabilityOptions = ObservabilityOptions() // new instance has reasonable defaults + private val options: ObservabilityOptions = ObservabilityOptions(), // new instance has reasonable defaults + private val customSessionId: String? = null, ) : Plugin() { var distroAttributes: Map = DEFAULT_DISTRO_ATTRIBUTES private val logger: ObserveLogger @@ -112,7 +116,7 @@ class Observability( LDObserve.context?.resourceAttributes = resource.attributes val observabilityService = ObservabilityService( - application, sdkKey, resource, logger, options, + application, sdkKey, resource, logger, options, customSessionId, ) observabilityClient = observabilityService LDObserve.context?.sessionManager = observabilityService.sessionManager diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 0d89a29798..bc535282ed 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -136,6 +136,9 @@ class LDObserve(private val client: Observe) : Observe { * @param replay Optional configuration for session replay. Pass `null` (the default) * to skip session replay initialization. * @param imageCaptureService Optional capture implementation for session replay. + * @param customSessionId Optional session id to adopt instead of generating one, so this + * instance can share a single `session.id` with another LaunchDarkly + * SDK on the device. When null, a session id is generated automatically. */ fun init( application: Application, @@ -144,6 +147,7 @@ class LDObserve(private val client: Observe) : Observe { observability: ObservabilityOptions = ObservabilityOptions(), replay: ReplayOptions? = null, imageCaptureService: ImageCaptureServicing? = null, + customSessionId: String? = null, ) { val logger = ObserveLogger.build(observability.logAdapter, observability.loggerName, observability.debug) @@ -164,7 +168,7 @@ class LDObserve(private val client: Observe) : Observe { // NOTE: the calling thread must not hold any lock the main thread is waiting on, or // this will deadlock — see runOnMainThread KDoc. runOnMainThread { - installObservability(application, mobileKey, resource, logger, observability, obsContext) + installObservability(application, mobileKey, resource, logger, observability, obsContext, customSessionId) if (replay != null) { installSessionReplay( replay, @@ -189,9 +193,10 @@ class LDObserve(private val client: Observe) : Observe { logger: ObserveLogger, options: ObservabilityOptions, obsContext: ObservabilityContext, + customSessionId: String? = null, ) { val service = ObservabilityService( - application, mobileKey, resource, logger, options, + application, mobileKey, resource, logger, options, customSessionId, ) obsContext.sessionManager = service.sessionManager obsContext.userInteractionManager = service.userInteractionManager diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt new file mode 100644 index 0000000000..3ba2a22081 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt @@ -0,0 +1,17 @@ +package io.opentelemetry.android + +import io.opentelemetry.android.session.SessionManager + +/** + * Bridges to [OpenTelemetryRumBuilder.setSessionManager], which is package-private in + * OpenTelemetry Android. Declaring this object in the same `io.opentelemetry.android` package lets + * the LaunchDarkly SDK supply its own [SessionManager] (see + * [com.launchdarkly.observability.client.LDSessionManager]) so it backs the RUM SDK's `session.id` + * span/log appenders, rather than relying on the default manager captured via an instrumentation. + */ +internal object LDRumSessionManagerAccessor { + fun setSessionManager( + builder: OpenTelemetryRumBuilder, + sessionManager: SessionManager, + ): OpenTelemetryRumBuilder = builder.setSessionManager(sessionManager) +} diff --git a/sdk/@launchdarkly/observability-react-native/README.md b/sdk/@launchdarkly/observability-react-native/README.md index 846e56b935..3107a09423 100644 --- a/sdk/@launchdarkly/observability-react-native/README.md +++ b/sdk/@launchdarkly/observability-react-native/README.md @@ -41,7 +41,7 @@ const client = new LDClient( ## Guides -- [Distributed Tracing Guide](guides/distributed-tracing.md) — a cookbook of common tracing patterns (spans, nested operations, error handling, correlated logs, and end-to-end mobile-to-backend traces). +- [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 diff --git a/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md b/sdk/@launchdarkly/observability-react-native/guides/tracing.md similarity index 65% rename from sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md rename to sdk/@launchdarkly/observability-react-native/guides/tracing.md index d09e837866..f76c99c16a 100644 --- a/sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md +++ b/sdk/@launchdarkly/observability-react-native/guides/tracing.md @@ -1,6 +1,6 @@ -# Distributed Tracing Guide +# Tracing Guide -This cookbook covers common distributed tracing patterns with the LaunchDarkly Observability SDK for React Native. Each recipe is self-contained and demonstrates a single concept with realistic examples. +This cookbook covers common tracing patterns with the LaunchDarkly Observability SDK for React Native — from simple spans to nested operations, error handling, correlated logs, and end-to-end mobile-to-backend distributed traces. Each recipe is self-contained and demonstrates a single concept with realistic examples. All examples assume the SDK has already been initialized (see [Usage](../README.md#usage)) and the following imports are present: @@ -11,13 +11,86 @@ import { context, trace, SpanStatusCode } from '@opentelemetry/api' Spans returned by the SDK are standard OpenTelemetry [`Span`](https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Span.html) objects, so every span operation in this guide is the regular OpenTelemetry API. -> **A note on context propagation in React Native.** Unlike .NET's `AsyncLocal`, JavaScript does not automatically carry the active span across `await`, `setTimeout`, `Promise` callbacks, or event handlers in React Native. `startActiveSpan` makes a span active only for the synchronous portion of its callback (and any `await`s within the same callback). When you cross into a *detached* callback, you must pass context explicitly — see recipes 7 and 8. +> **A note on context propagation in React Native.** React Native uses OpenTelemetry's `StackContextManager` (there is no `AsyncLocalStorage`/`AsyncLocal` equivalent), so the active span is tracked **only synchronously**. It is **not** restored after an `await`, `setTimeout`, `Promise` callback, or event handler — including across `await`s *within the same* `startActiveSpan` callback. In practice this means anything created in the synchronous part of a callback (before the first `await`) is parented automatically, but any span or log created **after an `await`** would become a new root unless you parent it explicitly. +> +> The SDK provides [`LDObserve.withSpan`](#0-recommended-withspan-for-nested-async-work) to handle this for you — it ends spans automatically and parents children off a captured context, so the hierarchy survives `await`s without manual plumbing. **Prefer `withSpan` for nested async work.** The lower-level recipes below also show the explicit `startActiveSpan`/`startSpan` + `ctx` form so you understand what `withSpan` does under the hood, and for cases where you need full manual control. + +--- + +## 0. Recommended: `withSpan` for nested async work + +`LDObserve.withSpan(name, fn, options?)` starts a span, runs `fn` within it, and **ends it automatically** (status `OK` on success, `ERROR` with the error recorded if `fn` throws or rejects). The callback receives a `SpanScope` that solves the context-propagation problem above: + +- `scope.span` — the underlying OpenTelemetry span (set attributes, add events, etc.). +- `scope.child(name, fn, options?)` — start a child span parented to **this** scope. Because the parent comes from the captured scope rather than the (lost) active context, children nest correctly **even after an `await`**, and even across concurrent (`Promise.all`) work. +- `scope.active(fn)` — run `fn` with this span active. Use it to parent **auto-instrumented** `fetch`/`XMLHttpRequest` spans that start *after* an `await` (see [recipe 4](#4-automatic-fetch--xmlhttprequest-instrumentation-under-a-custom-parent)). +- `scope.ctx` — this span's `Context`, for the rare case you need to pass an explicit parent elsewhere (e.g. a `setTimeout` callback — see [recipe 8](#8-creating-a-child-span-where-automatic-propagation-wont-work)). + +The nested workflow from [recipe 2](#2-nested-spans-for-a-typical-react-native-workflow), written with `withSpan`: + +```typescript +async function loadProducts() { + return LDObserve.withSpan('LoadProducts', async (load) => { + const products = await load.child('FetchFromApi', async (fetchScope) => { + const response = await fetch('https://api.example.com/products') + fetchScope.span.setAttribute('http.status_code', response.status) + const json = await response.text() + + // Parents to FetchFromApi even though we are past two awaits. + return fetchScope.child('DeserializeJson', (parseScope) => { + const result = JSON.parse(json) as Product[] + parseScope.span.setAttribute('product_count', result.length) + return result + }) + }) + + // Parents to LoadProducts (not FetchFromApi) — uses the captured context. + load.child('RenderUI', (renderScope) => { + renderScope.span.setAttribute('product_count', products.length) + setProducts(products) + }) + + return products + }) +} +``` + +No `getContextFromSpan`, no `ctx` threading, no manual `span.end()`. The trace tree is identical to recipe 2: + +```mermaid +graph TD + A[LoadProducts] --> B[FetchFromApi] + B --> C[DeserializeJson] + A --> D[RenderUI] +``` + +**Concurrency bonus.** `startActiveSpan` shares a single active-context stack, so interleaved `Promise.all` work corrupts parentage. `withSpan` does not — each `child` parents off its own captured context: + +```typescript +await LDObserve.withSpan('LoadDashboard', async (root) => { + const [profile, feed] = await Promise.all([ + root.child('fetch.profile', async ({ span }) => { + const res = await fetch('https://api.example.com/me') + span.setAttribute('http.status_code', res.status) + return res.json() + }), + root.child('fetch.feed', async ({ span }) => { + const res = await fetch('https://api.example.com/feed') + span.setAttribute('http.status_code', res.status) + return res.json() + }), + ]) + // both spans correctly parent to LoadDashboard +}) +``` + +> `withSpan` is built entirely on the explicit `startSpan` + `parent` context mechanism shown in the recipes below — it is sugar, not magic. The one thing it cannot change is **auto-instrumented** `fetch`/XHR parenting after an `await`; use `scope.active(...)` there. --- ## 1. Start a Root Span -Create an independent span that begins a brand-new trace by passing `{ root: true }`. Use `startActiveSpan` so the span is active for the duration of the callback and ends automatically when the callback returns. +Create an independent span that begins a brand-new trace by passing `{ root: true }`. `startActiveSpan` makes the span active for the duration of the callback; as in standard OpenTelemetry, it does **not** end the span for you, so call `span.end()` when the work is done (otherwise the span is never exported). ```typescript LDObserve.startActiveSpan( @@ -30,6 +103,7 @@ LDObserve.startActiveSpan( // ... initialization work ... span.addEvent('home_screen_ready') + span.end() }, { root: true }, ) @@ -50,36 +124,66 @@ Use `{ root: true }` when you want a span that is guaranteed to start a new trac ## 2. Nested Spans for a Typical React Native Workflow -`startActiveSpan` automatically parents each new span under the currently active one. This is the most common pattern for tracing multi-step operations. Because the parent stays active for the entire `async` callback, spans started during `await`s nest correctly. +> For most code you should use [`LDObserve.withSpan`](#0-recommended-withspan-for-nested-async-work), which handles everything below automatically. This recipe shows the explicit form so you understand the underlying mechanism and can use it when you need full manual control. + +`startActiveSpan` parents each new span under the currently active one — but only **synchronously**. In React Native the active context is lost after every `await` (see the note above), so a span created after an `await` would become a new root instead of nesting. To keep the hierarchy, capture each parent's context with `LDObserve.getContextFromSpan` and pass it as the final argument when you start a child after an `await`. ```typescript async function loadProducts() { return LDObserve.startActiveSpan('LoadProducts', async (loadSpan) => { + // The active context is lost across each `await`, so capture parents and + // pass them explicitly to keep the LoadProducts > FetchFromApi > + // DeserializeJson / RenderUI hierarchy. + const loadCtx = LDObserve.getContextFromSpan(loadSpan) + const products = await LDObserve.startActiveSpan( 'FetchFromApi', async (fetchSpan) => { + const fetchCtx = LDObserve.getContextFromSpan(fetchSpan) const response = await fetch('https://api.example.com/products') fetchSpan.setAttribute('http.status_code', response.status) const json = await response.text() - return LDObserve.startActiveSpan('DeserializeJson', (parseSpan) => { - const parsed = JSON.parse(json) as Product[] - parseSpan.setAttribute('product_count', parsed.length) - return parsed - }) + // DeserializeJson runs after `await`, so parent it on fetchCtx. + const parsed = LDObserve.startActiveSpan( + 'DeserializeJson', + (parseSpan) => { + const result = JSON.parse(json) as Product[] + parseSpan.setAttribute('product_count', result.length) + parseSpan.end() + return result + }, + undefined, // no extra span options + fetchCtx, // explicit parent context + ) + fetchSpan.end() + return parsed }, + undefined, + loadCtx, // FetchFromApi is started synchronously, but passing loadCtx is + // harmless and keeps the intent explicit. ) - LDObserve.startActiveSpan('RenderUI', (renderSpan) => { - renderSpan.setAttribute('product_count', products.length) - setProducts(products) - }) + // RenderUI runs after the `await` above, so parent it on loadCtx. + LDObserve.startActiveSpan( + 'RenderUI', + (renderSpan) => { + renderSpan.setAttribute('product_count', products.length) + setProducts(products) + renderSpan.end() + }, + undefined, + loadCtx, + ) + loadSpan.end() return products }) } ``` +> Without the explicit `ctx` arguments, `DeserializeJson` and `RenderUI` — both created after an `await` — would have no `parent_span_id` and show up as separate root traces instead of children of `LoadProducts`. + The resulting trace tree: ```mermaid @@ -128,7 +232,7 @@ async function fetchUserProfile(userId: string): Promise { } ``` -> When you call `startActiveSpan`, the span ends automatically once the callback's returned promise settles — but ending it yourself in a `finally` block is harmless and makes the lifetime explicit. +> `startActiveSpan` does not end the span for you. Always call `span.end()` yourself — a `finally` block is a good place so the span is closed even if the callback throws or returns early. --- @@ -147,6 +251,7 @@ async function syncOrders() { const orders = (await response.json()) as Order[] span.setAttribute('order_count', orders.length) + span.end() }) } ``` @@ -158,7 +263,34 @@ graph TD A[SyncOrders] --> B["HTTP GET /orders (auto-instrumented)"] ``` -You do not need to create a span for the HTTP call itself; the SDK handles it. Your business-logic span provides the parent context. The key requirement is that the request happens while your span is **active** — i.e. inside a `startActiveSpan` callback. +You do not need to create a span for the HTTP call itself; the SDK handles it. Your business-logic span provides the parent context. The key requirement is that the request happens while your span is **active** — i.e. inside a `startActiveSpan` (or `withSpan`) callback. + +The same recipe with `withSpan` (note the request is in the synchronous window before the first `await`, so it auto-parents): + +```typescript +async function syncOrders() { + await LDObserve.withSpan('SyncOrders', async ({ span }) => { + span.setAttribute('sync.direction', 'pull') + const response = await fetch('https://api.example.com/orders?since=yesterday') + span.setAttribute('http.status_code', response.status) + const orders = (await response.json()) as Order[] + span.setAttribute('order_count', orders.length) + }) +} +``` + +> **After an `await`, the active context is gone**, so a `fetch` started later would create a *root* HTTP span. Auto-instrumentation reads the active context at call time and can't see your captured scope — so wrap such calls in `scope.active(...)`: +> +> ```typescript +> await LDObserve.withSpan('Checkout', async (scope) => { +> const cart = await loadCart() // <- await drops the active context +> // Re-establish it so the auto HTTP span parents to "Checkout": +> const res = await scope.active(() => +> fetch('https://api.example.com/checkout', { method: 'POST' }), +> ) +> scope.span.setAttribute('http.status_code', res.status) +> }) +> ``` --- @@ -185,6 +317,8 @@ async function processPayment(orderId: string, amount: number) { // Pass the active span so the error attaches to it instead of a new one LDObserve.recordError(error, { 'order.id': orderId }, { span }) throw err + } finally { + span.end() } }) } @@ -196,11 +330,14 @@ async function processPayment(orderId: string, amount: number) { ## 6. Correlated Logs Inside the Active Span -When a span is active, `LDObserve.recordLog` automatically picks up the ambient trace and span IDs from the active context. No extra work is needed to correlate them. +When a span is active, `LDObserve.recordLog` automatically picks up the ambient trace and span IDs from the active context. That works for logs emitted in the **synchronous** part of the callback. After an `await` the active context is gone (see the note above), so re-establish it with `context.with(capturedContext, ...)` for any log you emit later. ```typescript async function importCatalog(rows: AsyncIterable) { await LDObserve.startActiveSpan('ImportCatalog', async (span) => { + const ctx = LDObserve.getContextFromSpan(span) + + // Synchronous: picks up the active span automatically. LDObserve.recordLog('Import started', 'info', { source: 'csv' }) let imported = 0 @@ -211,12 +348,17 @@ async function importCatalog(rows: AsyncIterable) { span.setAttribute('imported_count', imported) - LDObserve.recordLog('Import completed', 'info', { imported_count: imported }) + // After the `await` loop the active context is gone, so re-establish it so + // this log still correlates with the ImportCatalog span. + context.with(ctx, () => { + LDObserve.recordLog('Import completed', 'info', { imported_count: imported }) + }) + span.end() }) } ``` -Both log records carry the same `traceId` and `spanId` as the `ImportCatalog` span, linking them together in your observability backend. +Both log records carry the same `traceId` and `spanId` as the `ImportCatalog` span, linking them together in your observability backend — the first because the span is active synchronously, the second because `context.with` re-establishes that span's context. --- @@ -268,11 +410,13 @@ function startBackgroundSync() { const response = await fetch('https://api.example.com/sync') childSpan.setAttribute('http.status_code', response.status) childSpan.addEvent('sync.complete') + childSpan.end() }, undefined, // no extra span options parentContext, // explicit parent context ) }, 0) + parentSpan.end() }) } ``` @@ -292,6 +436,7 @@ function startPolling() { (pollSpan) => { pollSpan.setAttribute('tick.time', new Date().toISOString()) // ... polling logic ... + pollSpan.end() }, undefined, parentContext, @@ -332,6 +477,8 @@ function processAnalyticsQueue(events: AnalyticsEvent[]) { } catch (err) { span.recordException(err as Error) span.setStatus({ code: SpanStatusCode.ERROR }) + } finally { + span.end() } }, { root: true }, @@ -366,6 +513,7 @@ async function downloadAndCacheImage(url: string) { span.addEvent('cache.write.completed') span.setAttribute('cache.path', path) + span.end() }) } ``` @@ -400,6 +548,7 @@ async function checkout(cartId: string) { body: JSON.stringify({ cartId }), }) span.setAttribute('http.status_code', response.status) + span.end() }) } ``` @@ -471,6 +620,7 @@ withTenantContext('acme', 'gold', () => { // api.example.com is a tracing origin -> the `baggage` header // (app.tenant_id=acme,app.user_tier=gold) is sent to the backend. await fetch('https://api.example.com/dashboard') + span.end() }) }) ``` @@ -510,6 +660,7 @@ Like trace headers, the `baggage` header is only attached to requests whose URL | Method | Parent | Returns | |---|---|---| +| `withSpan(name, fn, options?)` | Captured scope context (or `options.parent`); **auto-ends** the span | result of `fn` | | `startActiveSpan(name, fn, options?, ctx?)` | Current active span (or `ctx` if provided) | result of `fn` | | `startActiveSpan(name, fn, { root: true })` | None (new trace) | result of `fn` | | `startSpan(name, options?, ctx?)` | Current active span (or `ctx`); span is **not** made active | `Span` | @@ -519,7 +670,9 @@ Like trace headers, the `baggage` header is only attached to requests whose URL | `getContextFromSpan(span)` | -- | `Context` (for explicit re-parenting) | | `parseHeaders(headers)` | -- | `RequestContext` (`sessionId`, `requestId`) | -> `startActiveSpan` ends its span automatically when the callback returns (awaiting the returned promise). `startSpan` requires a manual `span.end()`. +> Both `startActiveSpan` and `startSpan` require a manual `span.end()`. The difference is that `startActiveSpan` makes the span active (the current parent) for the duration of its callback, while `startSpan` does not change the active context. `withSpan` is the recommended wrapper: it ends the span automatically and its `scope.child(...)` keeps nesting correct across `await`s. + +> **`withSpan` scope** — the callback receives a `SpanScope`: `scope.span` (the OTel `Span`), `scope.child(name, fn, options?)` (start a correctly-parented child), `scope.active(fn)` (run with this span active, e.g. for auto-instrumented `fetch`/XHR after an `await`), and `scope.ctx` (the span's `Context`). ### Span Operations (OpenTelemetry `Span`) diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index 5cae3a490b..b4ddbc2f99 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts @@ -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 { /** @@ -116,6 +117,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( + spanName: string, + fn: (scope: SpanScope) => T, + options?: WithSpanOptions, + ): T + /** * Get the context from a span. * @param span The span to get the context from diff --git a/sdk/@launchdarkly/observability-react-native/src/api/SpanScope.ts b/sdk/@launchdarkly/observability-react-native/src/api/SpanScope.ts new file mode 100644 index 0000000000..b5894cade0 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/api/SpanScope.ts @@ -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( + 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(fn: () => T): T +} diff --git a/sdk/@launchdarkly/observability-react-native/src/api/index.ts b/sdk/@launchdarkly/observability-react-native/src/api/index.ts index e6b4b542da..f47a26d9c3 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/index.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/index.ts @@ -1,3 +1,4 @@ export * from './Options' export * from './Metric' export * from './RequestContext' +export type { SpanScope, WithSpanOptions } from './SpanScope' diff --git a/sdk/@launchdarkly/observability-react-native/src/constants/featureFlags.ts b/sdk/@launchdarkly/observability-react-native/src/constants/featureFlags.ts index 532d239d69..ec95ae9d96 100644 --- a/sdk/@launchdarkly/observability-react-native/src/constants/featureFlags.ts +++ b/sdk/@launchdarkly/observability-react-native/src/constants/featureFlags.ts @@ -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 diff --git a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts index 379e0448d4..da3e71eac2 100644 --- a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts +++ b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts @@ -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 { @@ -35,6 +37,7 @@ import { IdentifySeriesContext, EvaluationSeriesContext, EvaluationSeriesData, + TrackSeriesContext, } from '@launchdarkly/react-native-client-sdk' class TracingHook implements Hook { @@ -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 }) @@ -146,6 +152,39 @@ class TracingHook implements Hook { return data } + + afterTrack(hookContext: TrackSeriesContext): void { + try { + const trackAttributes: Attributes = { + ...this.metaAttributes, + ...(hookContext.context + ? getContextKeys(hookContext.context) + : {}), + key: hookContext.key, + ...(hookContext.metricValue !== undefined && + hookContext.metricValue !== null + ? { value: hookContext.metricValue } + : {}), + // Spread user-supplied track data last so it is attached as + // attributes. Non-primitive members are ignored by OpenTelemetry. + ...(typeof hookContext.data === 'object' && + hookContext.data !== null + ? (hookContext.data as Attributes) + : {}), + } + + _LDObserve.startActiveSpan(LD_TRACK_SPAN_NAME, (span) => { + span.setAttributes(trackAttributes) + span.setStatus({ code: 1 }) + span.end() + }) + } catch (error) { + _LDObserve.recordError(error as Error, { + 'track.key': hookContext.key, + 'error.context': 'track_tracing', + }) + } + } } export class Observability implements LDPlugin { diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index cd1e9e9f63..862ec0898e 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -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 @@ -127,6 +129,18 @@ class LDObserveClass : fn(noOpSpan.setAttribute('method', 'startActiveSpan')) } + withSpan( + 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() diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/withSpan.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/withSpan.ts new file mode 100644 index 0000000000..a26bb9c564 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/withSpan.ts @@ -0,0 +1,105 @@ +import { + Attributes, + context, + Context, + Span as OtelSpan, + SpanOptions, + SpanStatusCode, + trace, +} from '@opentelemetry/api' +import { SpanScope, WithSpanOptions } from '../api/SpanScope' +import { noOpSpan } from '../utils/NoOpSpan' + +/** + * The minimal surface {@link runInSpan} needs. Satisfied directly by the + * `LDObserve` singleton when loaded, and by {@link NOOP_SPAN_OPS} before init. + * + * Internal: not part of the public API. + */ +export interface SpanOps { + startSpan(spanName: string, options?: SpanOptions, ctx?: Context): OtelSpan + recordError( + error: Error, + attributes?: Attributes, + options?: { span: OtelSpan }, + ): void +} + +/** + * Used before the client is initialized: produces no-op spans and swallows + * errors, while still running the callback so application logic is unaffected. + * + * Internal: not part of the public API. + */ +export const NOOP_SPAN_OPS: SpanOps = { + startSpan: () => noOpSpan, + recordError: () => {}, +} + +/** + * Shared implementation behind `LDObserve.withSpan` and {@link SpanScope.child}. + * Parents the span off the supplied/active context, runs `fn` with the span + * active (so synchronous auto-instrumented calls are parented), and ends the + * span — handling both sync and promise returns. + * + * Internal: not part of the public API. + */ +export function runInSpan( + ops: SpanOps, + name: string, + fn: (scope: SpanScope) => T, + options?: WithSpanOptions, +): T { + const { parent: parentOption, ...spanOptions } = options ?? {} + const parent = parentOption ?? context.active() + const span = ops.startSpan(name, spanOptions, parent) + const ctx = trace.setSpan(parent, span) + + const scope: SpanScope = { + span, + ctx, + child: (childName, childFn, childOptions) => + runInSpan(ops, childName, childFn, { + ...childOptions, + parent: ctx, + }), + active: (activeFn) => context.with(ctx, activeFn), + } + + const succeed = () => { + span.setStatus({ code: SpanStatusCode.OK }) + span.end() + } + const failWith = (error: unknown) => { + try { + ops.recordError(error as Error, undefined, { span }) + } catch { + // recordError should never throw, but never mask the original error. + } + span.setStatus({ code: SpanStatusCode.ERROR }) + span.end() + } + + try { + // Make the span active for the synchronous window so auto-instrumented + // fetch/XHR calls started before the first `await` parent to it. + const result = context.with(ctx, () => fn(scope)) + if (result instanceof Promise) { + return result.then( + (value) => { + succeed() + return value + }, + (error) => { + failWith(error) + throw error + }, + ) as unknown as T + } + succeed() + return result + } catch (error) { + failWith(error) + throw error + } +} diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec b/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec index f08f32fd67..00b0c17646 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec +++ b/sdk/@launchdarkly/react-native-ld-session-replay/SessionReplayReactNative.podspec @@ -16,7 +16,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm,swift,cpp}" s.private_header_files = "ios/**/*.h" - s.dependency 'LaunchDarklySessionReplay', '~> 0.33.1' + s.dependency 'LaunchDarklySessionReplay', '~> 0.46.1' install_modules_dependencies(s) end diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt index 3360f685f5..763cbf80e1 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt @@ -25,6 +25,8 @@ internal class SessionReplayClientAdapter private constructor() { private val lock = Any() private var mobileKey: String? = null private var serviceName: String = DEFAULT_SERVICE_NAME + // Optional `service.version` forwarded from JS. null keeps the SDK default. + private var serviceVersion: String? = null private var replayOptions: ReplayOptions? = null // Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks). private var initialized = false @@ -40,6 +42,9 @@ internal class SessionReplayClientAdapter private constructor() { ?.getString("serviceName") ?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVICE_NAME + this.serviceVersion = options?.takeIf { it.hasKey("serviceVersion") } + ?.getString("serviceVersion") + ?.takeIf { it.isNotBlank() } this.replayOptions = replayOptionsFrom(options) } } @@ -47,6 +52,7 @@ internal class SessionReplayClientAdapter private constructor() { fun start(application: Application, activity: Activity?, completion: (Boolean, String?) -> Unit) { val localMobileKey: String? val localServiceName: String + val localServiceVersion: String? val localReplayOptions: ReplayOptions? // Capture configuration under the lock, then release it before posting to the main thread. @@ -54,6 +60,7 @@ internal class SessionReplayClientAdapter private constructor() { localMobileKey = mobileKey localReplayOptions = replayOptions localServiceName = serviceName + localServiceVersion = serviceVersion } if (localMobileKey == null || localReplayOptions == null) { val msg = "start: configure() was not called — mobile key or options are missing" @@ -68,7 +75,7 @@ internal class SessionReplayClientAdapter private constructor() { Handler(Looper.getMainLooper()).post { if (!initialized) { try { - initLDClient(application, localMobileKey, localServiceName, localReplayOptions) + initLDClient(application, localMobileKey, localServiceName, localServiceVersion, localReplayOptions) } catch (e: Exception) { logger.error("start: LDClient.init() threw {0}: {1}", e::class.simpleName, e.message) completion(false, "Session replay failed to initialize.") @@ -126,8 +133,26 @@ internal class SessionReplayClientAdapter private constructor() { } } - private fun initLDClient(application: Application, mobileKey: String, serviceName: String, replayOptions: ReplayOptions) { + private fun initLDClient( + application: Application, + mobileKey: String, + serviceName: String, + serviceVersion: String?, + replayOptions: ReplayOptions + ) { logger.debug("initLDClient: calling LDClient.init()") + // Apply the forwarded service.version only when provided; otherwise keep the SDK default. + var observabilityOptions = ObservabilityOptions( + serviceName = serviceName, + logAdapter = LDObserveLogging.adapter(), + // Disable the OpenTelemetry Android CrashReporterInstrumentation + instrumentations = ObservabilityOptions.Instrumentations( + crashReporting = false, + ), + ) + if (serviceVersion != null) { + observabilityOptions = observabilityOptions.copy(serviceVersion = serviceVersion) + } val config = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled) .mobileKey(mobileKey) .offline(true) @@ -139,14 +164,7 @@ internal class SessionReplayClientAdapter private constructor() { Observability( application = application, mobileKey = mobileKey, - options = ObservabilityOptions( - serviceName = serviceName, - logAdapter = LDObserveLogging.adapter(), - // Disable the OpenTelemetry Android CrashReporterInstrumentation - instrumentations = ObservabilityOptions.Instrumentations( - crashReporting = false, - ), - ) + options = observabilityOptions ), SessionReplay(options = replayOptions), ) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile b/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile index 6a4c5f1718..f8d88617a9 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/Gemfile @@ -14,3 +14,6 @@ gem 'bigdecimal' gem 'logger' gem 'benchmark' gem 'mutex_m' + +# Ruby 4.0.0 removed nkf/kconv from the standard library; CFPropertyList needs it. +gem 'nkf' diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/metro.config.js b/sdk/@launchdarkly/react-native-ld-session-replay/example/metro.config.js index 2da198e82c..68cc67a173 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/metro.config.js +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/metro.config.js @@ -4,13 +4,67 @@ const { withMetroConfig } = require('react-native-monorepo-config'); const root = path.resolve(__dirname, '..'); +// The example imports sibling workspace packages (e.g. +// @launchdarkly/observability-react-native and @launchdarkly/observability-shared), +// which live outside `root`. Metro must watch their real paths to resolve the +// workspace symlinks; the sdk/@launchdarkly directory covers them all without +// dragging in the much larger rrweb/ and e2e/ trees. +const sdkPackages = path.resolve(__dirname, '../..'); +// Several of those packages' dependencies (@opentelemetry/*, graphql) are +// hoisted to the monorepo root node_modules, so it must be resolvable too. +const monorepoNodeModules = path.resolve(__dirname, '../../../../node_modules'); + /** * Metro configuration * https://facebook.github.io/metro/docs/configuration * * @type {import('metro-config').MetroConfig} */ -module.exports = withMetroConfig(getDefaultConfig(__dirname), { +const config = withMetroConfig(getDefaultConfig(__dirname), { root, dirname: __dirname, }); + +config.watchFolders = [ + ...(config.watchFolders || []), + sdkPackages, + monorepoNodeModules, +]; + +config.resolver.nodeModulesPaths = [ + ...(config.resolver.nodeModulesPaths || []), + monorepoNodeModules, +]; + +// This monorepo has SEVERAL different react-native versions installed (the root +// has 0.76.x, @launchdarkly/observability-react-native ships its own 0.79.x as a +// dev dependency, the example builds against 0.83.x, etc.). If any package +// resolves react / react-native from its own copy, the bundle ends up with a +// duplicate JS runtime whose TurboModule registry isn't wired to the native +// binary — producing "getEnforcing(...) could not be found" for both our module +// AND core modules like PlatformConstants (used by AppState in the observability +// plugin). Force every react / react-native import, from anywhere in the graph, +// to resolve to the example's single copy. +const forcedSingletons = { + react: path.resolve(__dirname, 'node_modules/react'), + 'react-native': path.resolve(__dirname, 'node_modules/react-native'), +}; +const defaultResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + for (const [name, target] of Object.entries(forcedSingletons)) { + if (moduleName === name || moduleName.startsWith(`${name}/`)) { + return context.resolveRequest( + context, + target + moduleName.slice(name.length), + platform + ); + } + } + return (defaultResolveRequest || context.resolveRequest)( + context, + moduleName, + platform + ); +}; + +module.exports = config; diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx new file mode 100644 index 0000000000..f9dc9b994a --- /dev/null +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx @@ -0,0 +1,490 @@ +import { useState } from 'react'; +import { + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { LDObserve } from '@launchdarkly/observability-react-native'; +import { useLDClient } from '@launchdarkly/react-native-client-sdk'; +import { context } from '@opentelemetry/api'; + +/** + * Manual test screen that mirrors the iOS TestApp `MainMenuViewModel` + * (swift-launchdarkly-observability/TestApp/Sources/MainMenuViewModel.swift), + * exercising the public Observability + LaunchDarkly client APIs from React + * Native. + * + * The iOS sample has both `LDObserve.track` and `LDClient.track` variants. The + * React Native Observability API does not expose `track`, so every track recipe + * here goes through the LaunchDarkly client's `track` (the Observability plugin + * turns it into a span via its afterTrack hook). + */ + +// Public endpoint used by the network-request recipe. +const NETWORK_URL = 'https://launchdarkly.com/'; +// Endpoints used by the nested-span recipe (mirrors the Swift demo). +const GOOGLE_URL = 'https://www.google.com'; +const ANDROID_URL = 'https://www.android.com/'; + +export default function ApiScreen() { + const ldClient = useLDClient(); + const [lines, setLines] = useState([]); + + const log = (msg: string) => + setLines((prev) => + [`${new Date().toLocaleTimeString()} ${msg}`, ...prev].slice(0, 200) + ); + + // Wrap each recipe so a thrown error is surfaced in the log instead of + // crashing the screen. + const run = (label: string, fn: () => void | Promise) => async () => { + try { + await fn(); + } catch (err) { + log(`✗ ${label} threw: ${(err as Error).message}`); + } + }; + + // -- Errors -------------------------------------------------------------- + const recordError = () => { + LDObserve.recordError(new Error('Demo failure: crash'), {}); + log('[error] recordError(Demo failure)'); + }; + + // -- Span + flag variation ---------------------------------------------- + const recordSpanAndVariation = () => { + const span = LDObserve.startSpan('button-pressed'); + const value = ldClient.boolVariation('feature1', false); + span.setAttribute('feature1', value); + span.end(); + log(`[span] button-pressed + boolVariation(feature1)=${value}`); + }; + + // -- Nested spans (counter + log + network inside) ----------------------- + const triggerNestedSpans = async () => { + await LDObserve.startActiveSpan( + 'NestedSpan', + async (span0) => { + span0.setAttribute('test-true', true); + span0.setAttribute('test-double', 3.14); + await LDObserve.startActiveSpan('NestedSpan1', async (span1) => { + await LDObserve.startActiveSpan('NestedSpan2', async (span2) => { + LDObserve.recordCount({ name: 'NestedCounter', value: 10.0 }); + LDObserve.recordLog('NestedLog', 'info', {}); + await fetchUrlsForNestedSpanDemo(); + span2.end(); + }); + span1.end(); + }); + span0.end(); + } + ); + log('[nested] NestedSpan > NestedSpan1 > NestedSpan2 (+ counter, log, http)'); + }; + + const fetchUrlsForNestedSpanDemo = async () => { + try { + await fetch(GOOGLE_URL); + await fetch(ANDROID_URL); + } catch { + // ignore network errors in the demo + } + }; + + // -- Metrics ------------------------------------------------------------- + const recordMetric = () => { + LDObserve.recordMetric({ name: 'test-gauge', value: 50.0 }); + log('[metric] gauge test-gauge=50'); + }; + + const recordHistogramMetric = () => { + LDObserve.recordHistogram({ name: 'test-histogram', value: 15.0 }); + log('[metric] histogram test-histogram=15'); + }; + + const recordCounterMetric = () => { + LDObserve.recordCount({ name: 'test-counter', value: 10.0 }); + log('[metric] counter test-counter=10'); + }; + + const recordIncrementalMetric = () => { + LDObserve.recordIncr({ name: 'test-incremental-counter', value: 12.0 }); + log('[metric] incr test-incremental-counter=12'); + }; + + const recordUpDownCounterMetric = () => { + LDObserve.recordUpDownCounter({ name: 'test-up-down-counter', value: 25.0 }); + log('[metric] up/down test-up-down-counter=25'); + }; + + // -- Logs ---------------------------------------------------------------- + const recordLogWithContext = () => { + const span = LDObserve.startSpan('log-context-demo', { + attributes: { demo: 'log-with-context' }, + }); + const capturedContext = LDObserve.getContextFromSpan(span); + span.end(); + + // Simulate a detached task where the active OTel context is lost; re-attach + // the captured span context so the log stays correlated to the span. + setTimeout(() => { + context.with(capturedContext, () => { + LDObserve.recordLog('Log with span context', 'warn', { + source: 'detached-queue-demo', + }); + }); + log('[log] recordLog correlated to log-context-demo span'); + }, 0); + }; + + const recordLogs = () => { + LDObserve.recordLog('logs-button-pressed', 'info', { + 'test-string': 'react-native', + 'test-true': true, + 'test-false': false, + 'test-integer': 42, + 'test-long': 9_000_000_000, + 'test-double': 3.14, + // OTel attributes only allow primitives or homogeneous arrays of + // primitives, so the iOS sample's nested map is represented as JSON here. + 'test-array': [3.14], + 'test-nested': JSON.stringify({ array: [1] }), + }); + log('[log] recordLog(logs-button-pressed) with mixed attributes'); + }; + + // -- Track (via LaunchDarkly client) ------------------------------------- + const trackViaLDClient = () => { + ldClient.track('track-via-ld-client', { + 'test-string': 'react-native', + 'test-true': true, + 'test-false': false, + 'test-integer': 42, + 'test-double': 3.14, + 'test-long-number': 9_000_000_000_123, + }); + log('[track] ldClient.track(track-via-ld-client)'); + }; + + const trackNested = () => { + ldClient.track('checkout-started', { + 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 }, + ], + }); + log('[track] ldClient.track(checkout-started) nested payload'); + }; + + // -- Network ------------------------------------------------------------- + const performNetworkRequest = async () => { + try { + await fetch(NETWORK_URL); + log('[network] GET launchdarkly.com'); + } catch { + log('[network] GET launchdarkly.com (failed, ignored)'); + } + }; + + // -- Identify ------------------------------------------------------------ + const identifyUser = async () => { + await ldClient.identify({ + kind: 'user', + key: 'single-userkey', + firstName: 'Bob', + lastName: 'Bobberson', + }); + log('[identify] user single-userkey'); + }; + + const identifyAnonymous = async () => { + await ldClient.identify({ kind: 'user', anonymous: true }); + log('[identify] anonymous user'); + }; + + const identifyMulti = async () => { + await ldClient.identify({ + kind: 'multi', + user: { + key: 'multi-username', + name: 'multi-username', + anonymous: false, + customerNumber: '654321', + firstName: 'Bob', + lastName: 'Bobberson', + email: 'multi@multi.com', + }, + device: { + key: 'iphone', + name: 'iphone', + anonymous: false, + platform: 'ios', + appVersion: '10.3.2.1', + }, + }); + log('[identify] multi (user + device)'); + }; + + // -- Crash --------------------------------------------------------------- + const crash = () => { + // Throw outside the `run` try/catch so it surfaces as an unhandled error, + // mirroring the iOS `fatalError()` demo button. + setTimeout(() => { + throw new Error('Forced crash from API demo'); + }, 0); + log('[crash] scheduling unhandled error…'); + }; + + const flush = run('flush', async () => { + await LDObserve.flush(); + log('[i] flushed telemetry'); + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setLines([])} + variant="danger" + /> + + + + + Output log + + {lines.length === 0 ? ( + No output yet — tap a button. + ) : ( + lines.map((line, i) => ( + + {line} + + )) + )} + + + + ); +} + +function SectionHeader({ + title, + topSpacing, +}: { + title: string; + topSpacing?: boolean; +}) { + return ( + <> + + {title} + + + + ); +} + +function Btn({ + label, + onPress, + variant, +}: { + label: string; + onPress: () => void; + variant?: 'default' | 'danger' | 'identify'; +}) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#000', + }, + scroll: { + padding: 16, + paddingBottom: 24, + }, + sectionTitle: { + color: '#fff', + fontSize: 20, + fontWeight: 'bold', + }, + divider: { + height: 1, + backgroundColor: '#555', + marginTop: 4, + marginBottom: 8, + }, + col: { + gap: 8, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + btn: { + backgroundColor: '#6650A4', + borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 16, + alignItems: 'center', + }, + btnDanger: { + backgroundColor: '#F2B8B5', + }, + // Matches the iOS TestApp identify buttons (Colors.identifyBgColor / + // Colors.identifyTextColor in MainMenuView.swift). + btnIdentify: { + backgroundColor: '#121D61', + }, + btnIdentifyText: { + color: '#8A9EFF', + }, + btnText: { + color: '#fff', + fontWeight: '600', + }, + logContainer: { + height: 200, + borderTopWidth: 1, + borderTopColor: '#333', + backgroundColor: '#0B0B0B', + paddingHorizontal: 12, + paddingTop: 8, + }, + logTitle: { + color: '#888', + fontSize: 12, + fontWeight: '600', + textTransform: 'uppercase', + marginBottom: 4, + }, + log: { + flex: 1, + }, + logScroll: { + paddingBottom: 12, + }, + logEmpty: { + color: '#555', + fontSize: 13, + fontStyle: 'italic', + }, + logLine: { + color: '#A5D6A7', + fontSize: 12, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + paddingVertical: 1, + }, +}); diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx index 300f486524..f2d4059121 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/App.tsx @@ -16,13 +16,19 @@ import { Observability } from '@launchdarkly/observability-react-native'; import DialogsScreen from './DialogsScreen'; import MaskingScreen from './MaskingScreen'; import TracingScreen from './TracingScreen'; +import ApiScreen from './ApiScreen'; const plugin = createSessionReplayPlugin({ isEnabled: true, + // Forwarded to the native observability + session replay instances so their + // spans report the same service.name / service.version as the JS observability + // plugin below. serviceVersion only affects observability-emitted signals. + serviceName: 'session-replay-rn-example', + serviceVersion: '1.0.0', maskTextInputs: true, maskWebViews: true, - maskLabels: true, - maskImages: true, + maskLabels: false, + maskImages: false, maskTestIDs: ['password', 'ssn'], unmaskTestIDs: ['safe'], minimumAlpha: 0.05, @@ -31,7 +37,7 @@ const plugin = createSessionReplayPlugin({ // The observability plugin powers the distributed tracing examples on the // "Tracing" tab. `tracingOrigins` opts the demo API hosts into W3C // `traceparent` / `baggage` header propagation so device spans can link to a -// backend trace (see the distributed-tracing guide, sections 11 and 12). +// backend trace (see the tracing guide, sections 11 and 12). const observability = new Observability({ serviceName: 'session-replay-rn-example', serviceVersion: '1.0.0', @@ -44,12 +50,15 @@ const observability = new Observability({ const MOBILE_KEY = process.env.LAUNCHDARKLY_MOBILE_KEY || 'YOUR_LAUNCHDARKLY_MOBILE_KEY_HERE'; +// Observability must come before the session replay plugin: the replay plugin +// reads the observability session id during registration so the native replay / +// observability instance can adopt it (shared `session.id` across JS + native). const client = new ReactNativeLDClient(MOBILE_KEY, AutoEnvAttributes.Enabled, { - plugins: [plugin, observability], + plugins: [observability, plugin], }); const context = { kind: 'user', key: 'user-key-123abc' }; -type Tab = 'masking' | 'dialogs' | 'tracing'; +type Tab = 'masking' | 'dialogs' | 'api' | 'tracing'; export default function App() { const [tab, setTab] = useState('masking'); @@ -72,6 +81,11 @@ export default function App() { active={tab === 'dialogs'} onPress={() => setTab('dialogs')} /> + setTab('api')} + /> {tab === 'masking' && } {tab === 'dialogs' && } + {tab === 'api' && } {tab === 'tracing' && } diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx index ca60a8e851..50d0c40a54 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/TracingScreen.tsx @@ -18,8 +18,8 @@ import { /** * Manual test screen that exercises every recipe from the React Native - * distributed tracing guide: - * sdk/@launchdarkly/observability-react-native/guides/distributed-tracing.md + * tracing guide: + * sdk/@launchdarkly/observability-react-native/guides/tracing.md * * Each button runs one recipe and appends a line (with the resulting trace/span * IDs where relevant) to the on-screen log. The observability plugin is wired up @@ -78,6 +78,7 @@ export default function TracingScreen() { span.addEvent('splash_rendered'); span.addEvent('home_screen_ready'); log(`[1] root span "app-cold-start" trace=${traceOf(span)}`); + span.end(); }, { root: true } ); @@ -85,25 +86,30 @@ export default function TracingScreen() { // -- 2. Nested spans ----------------------------------------------------- const nestedSpans = async () => { - await LDObserve.startActiveSpan('LoadProducts', async () => { - const items = await LDObserve.startActiveSpan( - 'FetchFromApi', - async (fetchSpan) => { - const response = await fetch(POSTS_URL); - fetchSpan.setAttribute('http.status_code', response.status); - const json = await response.text(); - return LDObserve.startActiveSpan('DeserializeJson', (parseSpan) => { - const parsed = JSON.parse(json) as unknown[]; - parseSpan.setAttribute('product_count', parsed.length); - return parsed; - }); - } - ); - LDObserve.startActiveSpan('RenderUI', (renderSpan) => { - renderSpan.setAttribute('product_count', items.length); + // `withSpan` ends each span automatically and `scope.child` parents off the + // captured context, so the LoadProducts > FetchFromApi > DeserializeJson / + // RenderUI hierarchy survives the `await`s without threading context by hand + // (React Native's StackContextManager only tracks the active span + // synchronously). + const count = await LDObserve.withSpan('LoadProducts', async (load) => { + const items = await load.child('FetchFromApi', async (fetchScope) => { + const response = await fetch(POSTS_URL); + fetchScope.span.setAttribute('http.status_code', response.status); + const json = await response.text(); + // Parents to FetchFromApi even though we are past two awaits. + return fetchScope.child('DeserializeJson', (parseScope) => { + const result = JSON.parse(json) as unknown[]; + parseScope.span.setAttribute('product_count', result.length); + return result; + }); }); - log(`[2] LoadProducts > FetchFromApi > DeserializeJson (${items.length})`); + // Parents to LoadProducts (not FetchFromApi) — uses the captured context. + load.child('RenderUI', (renderScope) => { + renderScope.span.setAttribute('product_count', items.length); + }); + return items.length; }); + log(`[2] LoadProducts > FetchFromApi > DeserializeJson (${count})`); }; // -- 3. HTTP span with manual error handling ----------------------------- @@ -137,9 +143,10 @@ export default function TracingScreen() { // -- 4. Auto fetch instrumentation under a custom parent ----------------- const autoInstrumentedChild = async () => { - await LDObserve.startActiveSpan('SyncOrders', async (span) => { + await LDObserve.withSpan('SyncOrders', async ({ span }) => { span.setAttribute('sync.direction', 'pull'); - // The auto-instrumented fetch span becomes a child of SyncOrders. + // `withSpan` makes SyncOrders active for the synchronous window, so this + // fetch (started before the first await) auto-parents to it. const response = await fetch(`${POSTS_URL}?_limit=5`); span.setAttribute('http.status_code', response.status); const orders = (await response.json()) as unknown[]; @@ -163,13 +170,17 @@ export default function TracingScreen() { span.setAttribute('error.category', error.name); LDObserve.recordError(error, { 'order.id': 'order-42' }, { span }); log(`[5] ProcessPayment recorded exception: ${error.message}`); + } finally { + span.end(); } }); }; // -- 6. Correlated logs inside the active span --------------------------- const correlatedLogs = async () => { - await LDObserve.startActiveSpan('ImportCatalog', async (span) => { + await LDObserve.withSpan('ImportCatalog', async ({ span, active }) => { + // Logs correlate to a span via the active context. The first log is in the + // synchronous window so it correlates automatically. LDObserve.recordLog('Import started', 'info', { source: 'demo' }); let imported = 0; for (const _row of [1, 2, 3, 4, 5]) { @@ -177,9 +188,13 @@ export default function TracingScreen() { imported++; } span.setAttribute('imported_count', imported); - LDObserve.recordLog('Import completed', 'info', { - imported_count: imported, - }); + // After the awaits the active context is gone — re-establish it with + // `active()` so this log still correlates to ImportCatalog. + active(() => + LDObserve.recordLog('Import completed', 'info', { + imported_count: imported, + }) + ); log(`[6] ImportCatalog: 2 correlated logs, trace=${traceOf(span)}`); }); }; @@ -206,22 +221,21 @@ export default function TracingScreen() { // -- 8a. Child span where automatic propagation won't work --------------- const detachedChildSpan = () => { - LDObserve.startActiveSpan('ScheduleSync', (parentSpan) => { - parentSpan.setAttribute('sync.mode', 'background'); - const parentContext = LDObserve.getContextFromSpan(parentSpan); - const tid = traceOf(parentSpan); + LDObserve.withSpan('ScheduleSync', ({ span, ctx }) => { + span.setAttribute('sync.mode', 'background'); + const tid = traceOf(span); - // setTimeout drops the active context -> pass it explicitly. + // setTimeout drops the active context -> capture ScheduleSync's `ctx` and + // pass it as the explicit `parent` once the timer fires. setTimeout(async () => { - await LDObserve.startActiveSpan( + await LDObserve.withSpan( 'BackgroundSync', - async (childSpan) => { + async ({ span: childSpan }) => { const response = await fetch(`${POSTS_URL}/1`); childSpan.setAttribute('http.status_code', response.status); childSpan.addEvent('sync.complete'); }, - undefined, - parentContext + { parent: ctx } ); log(`[8a] BackgroundSync re-parented to ScheduleSync trace=${tid}`); }, 0); @@ -242,14 +256,13 @@ export default function TracingScreen() { let ticks = 0; intervalRef.current = setInterval(() => { ticks++; - LDObserve.startActiveSpan( + LDObserve.withSpan( 'PollTick', - (pollSpan) => { + ({ span: pollSpan }) => { pollSpan.setAttribute('tick.time', new Date().toISOString()); pollSpan.setAttribute('tick.number', ticks); }, - undefined, - parentContext + { parent: parentContext } ); log(`[8b] PollTick #${ticks} parent trace=${tid}`); if (ticks >= 3 && intervalRef.current) { @@ -276,6 +289,7 @@ export default function TracingScreen() { span.setAttribute('event.user_id', evt.userId); span.setStatus({ code: SpanStatusCode.OK }); traces.push(traceOf(span)); + span.end(); }, { root: true } ); @@ -285,7 +299,7 @@ export default function TracingScreen() { // -- 10. Span events as lightweight checkpoints -------------------------- const spanEvents = async () => { - await LDObserve.startActiveSpan('DownloadAndCacheImage', async (span) => { + await LDObserve.withSpan('DownloadAndCacheImage', async ({ span }) => { span.setAttribute('image.url', IMAGE_URL); span.addEvent('download.started'); const response = await fetch(IMAGE_URL); @@ -301,7 +315,7 @@ export default function TracingScreen() { // -- 11. Connecting mobile traces to your backend ------------------------ const backendDistributedTrace = async () => { - await LDObserve.startActiveSpan('Checkout', async (span) => { + await LDObserve.withSpan('Checkout', async ({ span }) => { span.setAttribute('cart.id', 'cart-7'); // traceparent is injected automatically because the host is a tracing // origin -> a backend span would join this trace. @@ -353,6 +367,7 @@ export default function TracingScreen() { // Outgoing request to a tracing origin also carries the baggage header. await fetch(`${POSTS_URL}/1`); log(`[12] baggage tenant=${tenant} copied onto LoadDashboard span`); + span.end(); }); } ); @@ -377,11 +392,6 @@ export default function TracingScreen() { return ( - - Each button runs one recipe from the distributed tracing guide. Results - (with trace IDs) appear in the log below. - - @@ -519,12 +529,6 @@ const styles = StyleSheet.create({ padding: 16, paddingBottom: 24, }, - intro: { - color: '#CAC4D0', - fontSize: 14, - fontStyle: 'italic', - marginBottom: 12, - }, sectionTitle: { color: '#fff', fontSize: 20, diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift index b4a8dac52f..91385262a7 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift +++ b/sdk/@launchdarkly/react-native-ld-session-replay/ios/SessionReplayClientAdapter.swift @@ -11,6 +11,14 @@ public class SessionReplayClientAdapter: NSObject { private let lock = NSLock() private var mobileKey: String? private var sessionReplayOptions: SessionReplayOptions? + // Optional session id forwarded from the JS observability SDK so the native + // observability instance (which emits e.g. `click` spans) reports the same + // `session.id`. nil means the native SDK uses its own generated session. + private var customSessionId: String? + // Optional `service.version` forwarded from JS. Applied to the observability + // plugin only (the session replay options have no version). nil keeps the + // SDK default. + private var serviceVersion: String? // Each start()/stop() appends a new Task that awaits the previous one, serializing all work. private var lastTask: Task = Task {} @@ -32,6 +40,18 @@ public class SessionReplayClientAdapter: NSObject { } self.mobileKey = key self.sessionReplayOptions = sessionReplayOptionsFrom(dictionary: options) + if let sessionId = (options?["sessionId"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), !sessionId.isEmpty { + self.customSessionId = sessionId + } else { + self.customSessionId = nil + } + if let serviceVersion = (options?["serviceVersion"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), !serviceVersion.isEmpty { + self.serviceVersion = serviceVersion + } else { + self.serviceVersion = nil + } } private func makeConfig(mobileKey: String, options: SessionReplayOptions) -> LDConfig { @@ -39,15 +59,24 @@ public class SessionReplayClientAdapter: NSObject { mobileKey: mobileKey, autoEnvAttributes: .enabled ) + var observabilityOptions = ObservabilityOptions( + // Disable the plugin's auto-start so we can start observability ourselves + // (see start()) and inject the JS session id. The native `startSession` is + // guarded by `task == nil`, so a session id can only be supplied on the + // first start — which the auto-start would consume. + isEnabled: false, + serviceName: options.serviceName, + sessionBackgroundTimeout: 10, + /// Disable the underlying KSCrash-based crash reporter that + crashReporting: .init(source: .none) + ) + // The session replay options carry no version, so apply the forwarded + // `service.version` to the observability plugin only. + if let serviceVersion = self.serviceVersion { + observabilityOptions.serviceVersion = serviceVersion + } config.plugins = [ - Observability( - options: .init( - serviceName: options.serviceName, - sessionBackgroundTimeout: 10, - /// Disable the underlying KSCrash-based crash reporter that - crashReporting: .init(source: .none) - ) - ), + Observability(options: observabilityOptions), SessionReplay(options: options) ] /// we set the LDClient offline to stop communication with the LaunchDarkly servers. @@ -93,6 +122,7 @@ public class SessionReplayClientAdapter: NSObject { completion(false, "Client not initialized. Call SetMobileKey first.") return } + let customSessionId = self.customSessionId let prev = lastTask lastTask = Task { @MainActor [weak self] in await prev.value @@ -105,6 +135,16 @@ public class SessionReplayClientAdapter: NSObject { cont.resume() } } + // The Observability plugin is configured with isEnabled=false (see + // makeConfig), so it did not auto-start. Start it now: when a session id + // was forwarded from the JS observability SDK, adopt it so native spans + // (e.g. `click`) share the same `session.id`; otherwise start with a + // generated session. + if let customSessionId { + LDObserve.shared.start(sessionId: customSessionId) + } else { + LDObserve.shared.start() + } self.initialized = true } else { NSLog( diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts index 517288f362..00d1d5468d 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -2,7 +2,19 @@ import { TurboModuleRegistry, type TurboModule } from 'react-native'; export type SessionReplayOptions = { isEnabled?: boolean; + /** + * The OpenTelemetry `service.name` reported by the native session replay / + * observability instance. Applied to both the observability and session replay + * plugins on iOS and Android. + */ serviceName?: string; + /** + * The OpenTelemetry `service.version` reported by the native observability + * instance. Applied to the observability plugin on iOS and Android. The native + * session replay plugin does not expose a version, so this only affects + * observability-emitted signals. + */ + serviceVersion?: string; maskTextInputs?: boolean; maskWebViews?: boolean; maskLabels?: boolean; @@ -24,6 +36,17 @@ export type SessionReplayOptions = { * Defaults to `0.02`. Has no effect on Android. */ minimumAlpha?: number; + + /** + * Session id to adopt for the native session replay / observability instance, + * so its spans (e.g. `click`) share the same `session.id` as the JS + * observability SDK. When provided, the native side calls its observability + * `start(sessionId:)` so both pipelines report a single session. + * + * iOS only for now: the Android observability SDK does not yet expose a custom + * session API, so this value is ignored on Android. + */ + sessionId?: string; }; export interface Spec extends TurboModule { diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx index bda4fdfa7b..a92eeade46 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/index.tsx @@ -2,9 +2,10 @@ import { View, type StyleProp, type ViewStyle } from 'react-native'; import type { ReactNode } from 'react'; import SessionReplayReactNative from './NativeSessionReplayReactNative'; import type { SessionReplayOptions } from './NativeSessionReplayReactNative'; -import type { - LDPlugin, - LDClientMin, +import { + LDObserve, + type LDPlugin, + type LDClientMin, } from '@launchdarkly/observability-react-native'; import type { LDContext, @@ -201,7 +202,16 @@ class SessionReplayPluginAdapter implements LDPlugin { console.error('[SessionReplay]', MOBILE_KEY_REQUIRED_MESSAGE); return; } - configureSessionReplay(key, this.options) + // Adopt the JS observability session id so native spans (e.g. `click`) share + // the same `session.id`. This requires the observability plugin to have been + // registered before this one; if it hasn't initialized yet the id is empty + // and native keeps its own session. + const sessionId = this.resolveObservabilitySessionId(); + const options: SessionReplayOptions = sessionId + ? { ...this.options, sessionId } + : this.options; + + configureSessionReplay(key, options) .then(() => { return startSessionReplay(); }) @@ -212,6 +222,15 @@ class SessionReplayPluginAdapter implements LDPlugin { ); }); } + + private resolveObservabilitySessionId(): string | undefined { + try { + const id = LDObserve.getSessionInfo()?.sessionId; + return typeof id === 'string' && id.length > 0 ? id : undefined; + } catch { + return undefined; + } + } } export function createSessionReplayPlugin( diff --git a/yarn.lock b/yarn.lock index a77025f3d9..b621e408bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42783,8 +42783,10 @@ __metadata: "@babel/core": "npm:^7.25.2" "@babel/preset-env": "npm:^7.25.3" "@babel/runtime": "npm:^7.25.0" + "@launchdarkly/observability-react-native": "workspace:*" "@launchdarkly/react-native-client-sdk": "npm:^10.12.5" "@launchdarkly/session-replay-react-native": "workspace:*" + "@opentelemetry/api": "npm:^1.9.0" "@react-native-community/cli": "npm:20.0.0" "@react-native-community/cli-platform-android": "npm:20.0.0" "@react-native-community/cli-platform-ios": "npm:20.0.0" From d2376ab3a6c2c720fde74c23c7299e749833a0d2 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 10:30:52 -0700 Subject: [PATCH 08/21] adopting --- .../SessionReplayClientAdapter.kt | 18 ++++++++++++++++-- .../src/NativeSessionReplayReactNative.ts | 9 +++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt index 763cbf80e1..48d2f091be 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/src/main/java/com/sessionreplayreactnative/SessionReplayClientAdapter.kt @@ -27,6 +27,10 @@ internal class SessionReplayClientAdapter private constructor() { private var serviceName: String = DEFAULT_SERVICE_NAME // Optional `service.version` forwarded from JS. null keeps the SDK default. private var serviceVersion: String? = null + // Optional session id forwarded from the JS observability SDK so the native observability + // instance (which emits e.g. `click` spans) seeds its session manager with the same + // `session.id`. null means the native SDK generates and owns its own session. + private var customSessionId: String? = null private var replayOptions: ReplayOptions? = null // Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks). private var initialized = false @@ -45,6 +49,9 @@ internal class SessionReplayClientAdapter private constructor() { this.serviceVersion = options?.takeIf { it.hasKey("serviceVersion") } ?.getString("serviceVersion") ?.takeIf { it.isNotBlank() } + this.customSessionId = options?.takeIf { it.hasKey("sessionId") } + ?.getString("sessionId") + ?.takeIf { it.isNotBlank() } this.replayOptions = replayOptionsFrom(options) } } @@ -53,6 +60,7 @@ internal class SessionReplayClientAdapter private constructor() { val localMobileKey: String? val localServiceName: String val localServiceVersion: String? + val localCustomSessionId: String? val localReplayOptions: ReplayOptions? // Capture configuration under the lock, then release it before posting to the main thread. @@ -61,6 +69,7 @@ internal class SessionReplayClientAdapter private constructor() { localReplayOptions = replayOptions localServiceName = serviceName localServiceVersion = serviceVersion + localCustomSessionId = customSessionId } if (localMobileKey == null || localReplayOptions == null) { val msg = "start: configure() was not called — mobile key or options are missing" @@ -75,7 +84,7 @@ internal class SessionReplayClientAdapter private constructor() { Handler(Looper.getMainLooper()).post { if (!initialized) { try { - initLDClient(application, localMobileKey, localServiceName, localServiceVersion, localReplayOptions) + initLDClient(application, localMobileKey, localServiceName, localServiceVersion, localCustomSessionId, localReplayOptions) } catch (e: Exception) { logger.error("start: LDClient.init() threw {0}: {1}", e::class.simpleName, e.message) completion(false, "Session replay failed to initialize.") @@ -138,6 +147,7 @@ internal class SessionReplayClientAdapter private constructor() { mobileKey: String, serviceName: String, serviceVersion: String?, + customSessionId: String?, replayOptions: ReplayOptions ) { logger.debug("initLDClient: calling LDClient.init()") @@ -161,10 +171,14 @@ internal class SessionReplayClientAdapter private constructor() { listOf( // TODO: Pass JS ObservabilityOptions such as backendUrl, // resourceAttributes, and sessionBackgroundTimeout through to here. + // Forward the JS observability session id (when provided) so the native + // observability instance seeds its session manager with the same + // `session.id`, matching iOS. null lets the native SDK own its session. Observability( application = application, mobileKey = mobileKey, - options = observabilityOptions + options = observabilityOptions, + customSessionId = customSessionId ), SessionReplay(options = replayOptions), ) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts index 00d1d5468d..8a50501334 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts +++ b/sdk/@launchdarkly/react-native-ld-session-replay/src/NativeSessionReplayReactNative.ts @@ -40,11 +40,12 @@ export type SessionReplayOptions = { /** * Session id to adopt for the native session replay / observability instance, * so its spans (e.g. `click`) share the same `session.id` as the JS - * observability SDK. When provided, the native side calls its observability - * `start(sessionId:)` so both pipelines report a single session. + * observability SDK. When provided, the native side seeds its observability + * session with this id so both pipelines report a single session. * - * iOS only for now: the Android observability SDK does not yet expose a custom - * session API, so this value is ignored on Android. + * Supported on both platforms: iOS starts observability with + * `start(sessionId:)`, and Android forwards it as the `Observability` plugin's + * `customSessionId` (seeding its session manager). */ sessionId?: string; }; From 7407ddd039568f07383c04e80755dfcecea6fa54 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 10:34:39 -0700 Subject: [PATCH 09/21] update Android dependencies --- .../react-native-ld-session-replay/android/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle index 4c6406794d..852d492399 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle +++ b/sdk/@launchdarkly/react-native-ld-session-replay/android/build.gradle @@ -83,8 +83,8 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.launchdarkly:launchdarkly-observability-android:0.45.0" - implementation "com.launchdarkly:launchdarkly-android-client-sdk:0.46.1" + implementation "com.launchdarkly:launchdarkly-observability-android:0.58.0" + implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.13.1" // compileOnly: OTel Attributes appears in ObservabilityOptions parameter types; provided at // runtime transitively through launchdarkly-observability-android. compileOnly "io.opentelemetry:opentelemetry-api:1.51.0" From 9564d68e96b7a1e638fd38e88c4845ace3d6fae2 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 08:59:22 -0700 Subject: [PATCH 10/21] feat(observability-android): support external session id Introduce a LaunchDarkly-owned SessionManager (LDSessionManager) that can be seeded with an external session id, so the native instance can adopt a session id created elsewhere (e.g. the JS SDK in a React Native app) and report a single shared session.id across spans, logs, metrics, and replay. - LDSessionManager: seedable session manager replicating OTel Android's rotation semantics (foreground never expires, background inactivity and max-lifetime rotation) and observer notifications. - LDRumSessionManagerAccessor: same-package shim to call the package-private OpenTelemetryRumBuilder.setSessionManager, making our manager back the RUM SDK's session.id span/log appenders (single source of session identity). - ObservabilityService: inject the custom manager, drop the old session-manager-bridge instrumentation and SessionConfig, drive the background-timeout from the app lifecycle, and read session.id from it. - Thread an optional customSessionId through the Observability plugin and LDObserve.init for callers (RN integration to follow). Co-authored-by: Cursor --- .../observability/client/LDSessionManager.kt | 136 ++++++++++++++++++ .../client/ObservabilityService.kt | 46 +++--- .../observability/plugin/Observability.kt | 8 +- .../observability/sdk/LDObserve.kt | 9 +- .../android/LDRumSessionManagerAccessor.kt | 17 +++ 5 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt create mode 100644 sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt new file mode 100644 index 0000000000..35077df38c --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt @@ -0,0 +1,136 @@ +package com.launchdarkly.observability.client + +import io.opentelemetry.android.session.Session +import io.opentelemetry.android.session.SessionIdGenerator +import io.opentelemetry.android.session.SessionManager +import io.opentelemetry.android.session.SessionObserver +import io.opentelemetry.sdk.common.Clock +import java.util.Collections.synchronizedList +import kotlin.time.Duration + +/** + * LaunchDarkly's own [SessionManager], used in place of OpenTelemetry Android's default so we can + * **seed the initial session id** ([initialSessionId]). This lets the native observability instance + * adopt a session id created elsewhere (e.g. by the JavaScript SDK on the same device), so that + * spans, logs, metrics, and Session Replay all report the same `session.id`. + * + * Injected into [io.opentelemetry.android.OpenTelemetryRumBuilder] via + * [io.opentelemetry.android.LDRumSessionManagerAccessor] so it backs OpenTelemetry Android's own + * `session.id` span/log appenders as well — making it the single source of session identity. + * + * Rotation behavior mirrors OpenTelemetry Android's default `SessionManagerImpl` / + * `SessionIdTimeoutHandler`: + * - a foreground session never times out; + * - a background gap of at least [backgroundInactivityTimeout] rotates the session on next use; + * - a session older than [maxLifetime] rotates regardless of foreground/background. + * + * Foreground/background transitions are fed in via [onApplicationForegrounded] / + * [onApplicationBackgrounded] (wired from the service's app-lifecycle tracker). + * + * @param initialSessionId Optional session id to start with. When null or blank, an id is generated + * lazily on first use (matching the default manager). + * @param backgroundInactivityTimeout Background inactivity after which the session rotates. + * @param maxLifetime Absolute maximum session lifetime before rotation. + * @param clock Time source; defaults to the OpenTelemetry default clock. + * @param idGenerator Generator for new session ids; defaults to OpenTelemetry's. + */ +internal class LDSessionManager( + initialSessionId: String? = null, + private val backgroundInactivityTimeout: Duration, + private val maxLifetime: Duration, + private val clock: Clock = Clock.getDefault(), + private val idGenerator: SessionIdGenerator = SessionIdGenerator.DEFAULT, +) : SessionManager { + + private val lock = Any() + private val observers = synchronizedList(ArrayList()) + + // Guarded by [lock]. + private var session: Session = + if (!initialSessionId.isNullOrBlank()) { + Session.DefaultSession(initialSessionId, clock.now()) + } else { + // Empty id + (-1) timestamp forces generation on first getSessionId(), exactly like + // the default manager's initial Session.NONE. + Session.NONE + } + + // Timeout bookkeeping, mirroring SessionIdTimeoutHandler. + @Volatile + private var timeoutStartNanos: Long = clock.nanoTime() + + @Volatile + private var state: State = State.FOREGROUND + + override fun addObserver(observer: SessionObserver) { + observers.add(observer) + } + + override fun getSessionId(): String { + val newSession: Session + val previousSession: Session + val rotated: Boolean + + synchronized(lock) { + var candidate = session + if (sessionHasExpired() || hasTimedOut()) { + candidate = Session.DefaultSession(idGenerator.generateSessionId(), clock.now()) + } + // Bump the inactivity timer after deciding, before notifying (a new span may be created). + bump() + rotated = candidate !== session + previousSession = session + if (rotated) { + session = candidate + } + newSession = session + } + + if (rotated) { + // Notify outside the lock; observers may create spans which call back into getSessionId(). + observers.forEach { observer -> + observer.onSessionEnded(previousSession) + observer.onSessionStarted(newSession, previousSession) + } + } + return newSession.getId() + } + + /** Marks the app as transitioning to the foreground; the next event settles it to foreground. */ + fun onApplicationForegrounded() { + state = State.TRANSITIONING_TO_FOREGROUND + } + + /** Marks the app as backgrounded, after which the inactivity timeout can rotate the session. */ + fun onApplicationBackgrounded() { + state = State.BACKGROUND + } + + private fun sessionHasExpired(): Boolean { + val elapsed = clock.now() - session.getStartTimestamp() + return elapsed >= maxLifetime.inWholeNanoseconds + } + + private fun hasTimedOut(): Boolean { + // The session never times out while the app is in the foreground. + if (state == State.FOREGROUND) return false + val elapsed = clock.nanoTime() - timeoutStartNanos + return elapsed >= backgroundInactivityTimeout.inWholeNanoseconds + } + + private fun bump() { + timeoutStartNanos = clock.nanoTime() + // The first event after returning to the foreground settles the transitional state. + if (state == State.TRANSITIONING_TO_FOREGROUND) { + state = State.FOREGROUND + } + } + + private enum class State { + FOREGROUND, + BACKGROUND, + + /** Temporary state for the first event after the app is brought back to the foreground. */ + TRANSITIONING_TO_FOREGROUND, + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index dcf4bb3cc2..78a44f1fa1 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -33,13 +33,11 @@ import com.launchdarkly.observability.sampling.SamplingLogProcessor import com.launchdarkly.observability.traces.EventSpanProcessor import com.launchdarkly.observability.traces.OtlpTraceExporter import com.launchdarkly.observability.util.requireMainThread +import io.opentelemetry.android.LDRumSessionManagerAccessor import io.opentelemetry.android.OpenTelemetryRum import io.opentelemetry.android.OpenTelemetryRumBuilder import io.opentelemetry.android.config.OtelRumConfig -import io.opentelemetry.android.instrumentation.AndroidInstrumentation -import io.opentelemetry.android.instrumentation.InstallationContext import io.opentelemetry.android.session.Session -import io.opentelemetry.android.session.SessionConfig import io.opentelemetry.android.session.SessionManager import io.opentelemetry.android.session.SessionObserver import io.opentelemetry.api.common.AttributeKey @@ -73,6 +71,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.hours /** * The [ObservabilityService] can be used for recording observability data such as @@ -98,9 +97,22 @@ class ObservabilityService( private val resources: Resource, private val logger: ObserveLogger, private val observabilityOptions: ObservabilityOptions, + // Optional session id to adopt (e.g. forwarded from another LaunchDarkly SDK on the device) so + // all signals share one `session.id`. Null lets the manager generate its own. + private val customSessionId: String? = null, ) : Observe, TrackEmitting { private val otelRUM: OpenTelemetryRum - var sessionManager: SessionManager? = null + + // LaunchDarkly's own session manager. Owns session identity for all signals (it also backs the + // RUM SDK's `session.id` appenders via LDRumSessionManagerAccessor) and can be seeded with + // [customSessionId]. Exposed through [sessionManager] for Session Replay. + private val ldSessionManager = LDSessionManager( + initialSessionId = customSessionId, + backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout, + maxLifetime = 4.hours, + ) + + var sessionManager: SessionManager? = ldSessionManager private set private var otelMeter: Meter private var otelLogger: Logger @@ -209,8 +221,6 @@ class ObservabilityService( registerOtlpExporters() val otelRumConfig = createOtelRumConfig() - var capturedSessionManager: SessionManager? = null - val rumBuilder = OpenTelemetryRum.builder(application, otelRumConfig) .addLoggerProviderCustomizer { sdkLoggerProviderBuilder, _ -> return@addLoggerProviderCustomizer configureLoggerProvider(sdkLoggerProviderBuilder) @@ -222,22 +232,15 @@ class ObservabilityService( return@addMeterProviderCustomizer configureMeterProvider(sdkMeterProviderBuilder) } - rumBuilder.addInstrumentation(object : AndroidInstrumentation { - override val name = "ld-session-manager-bridge" - override fun install(ctx: InstallationContext) { - capturedSessionManager = ctx.sessionManager - } - }) + // Use our own session manager (instead of the RUM SDK's default) so we can seed the session + // id and keep a single source of session identity across spans, logs, metrics, and replay. + LDRumSessionManagerAccessor.setSessionManager(rumBuilder, ldSessionManager) if (observabilityOptions.instrumentations.launchTime) { addLaunchTimeInstrumentation(rumBuilder) } otelRUM = rumBuilder.build() - sessionManager = capturedSessionManager - if (sessionManager == null) { - logger.warn("SessionManager was not captured during OpenTelemetryRum.build(); session-dependent features will be unavailable.") - } // A new session (e.g. after a background timeout) must start with a fresh navigation // history, otherwise the first screen_view/Navigate of the new session would resolve @@ -410,8 +413,9 @@ class ObservabilityService( } private fun createOtelRumConfig(): OtelRumConfig { + // Session lifetime/rotation is owned by [ldSessionManager] (injected via + // [LDRumSessionManagerAccessor]), so no SessionConfig is applied here. val config = OtelRumConfig() - .setSessionConfig(SessionConfig(backgroundInactivityTimeout = observabilityOptions.sessionBackgroundTimeout)) if (!observabilityOptions.instrumentations.crashReporting) { // Disables [io.opentelemetry.android.instrumentation.crash.CrashReporterInstrumentation.java] @@ -772,6 +776,12 @@ class ObservabilityService( * navigation/track emitters. */ private fun handleAppLifecycleSignal(signal: AppLifecycleSignal) { + // Drive the session manager's background-inactivity timeout from the same lifecycle source. + when (signal.kind) { + AppLifecycleSignal.Kind.FOREGROUND -> ldSessionManager.onApplicationForegrounded() + AppLifecycleSignal.Kind.BACKGROUND -> ldSessionManager.onApplicationBackgrounded() + } + // Broadcast so Session Replay can emit a breadcrumb independent of the span flags below. _appLifecycleFlow.tryEmit(signal) @@ -912,7 +922,7 @@ class ObservabilityService( } } - private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, otelRUM.rumSessionId).build() + private fun Attributes.addSessionId() = this.toBuilder().put(SESSION_ID_ATTRIBUTE, ldSessionManager.getSessionId()).build() companion object { private const val METRICS_PATH = "/v1/metrics" diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt index eb1ce36e80..260a329960 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/plugin/Observability.kt @@ -47,11 +47,15 @@ import java.util.Collections * @param application The application instance. * @param options The options for the plugin. * @param mobileKey The primary mobile key used in LDConfig. + * @param customSessionId Optional session id to adopt instead of generating one. Lets the native + * instance share a single `session.id` with another LaunchDarkly SDK on the device (e.g. the + * JavaScript SDK in a React Native app). When null, a session id is generated automatically. */ class Observability( private val application: Application, private val mobileKey: String, - private val options: ObservabilityOptions = ObservabilityOptions() // new instance has reasonable defaults + private val options: ObservabilityOptions = ObservabilityOptions(), // new instance has reasonable defaults + private val customSessionId: String? = null, ) : Plugin() { var distroAttributes: Map = DEFAULT_DISTRO_ATTRIBUTES private val logger: ObserveLogger @@ -112,7 +116,7 @@ class Observability( LDObserve.context?.resourceAttributes = resource.attributes val observabilityService = ObservabilityService( - application, sdkKey, resource, logger, options, + application, sdkKey, resource, logger, options, customSessionId, ) observabilityClient = observabilityService LDObserve.context?.sessionManager = observabilityService.sessionManager diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt index 0d89a29798..bc535282ed 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/sdk/LDObserve.kt @@ -136,6 +136,9 @@ class LDObserve(private val client: Observe) : Observe { * @param replay Optional configuration for session replay. Pass `null` (the default) * to skip session replay initialization. * @param imageCaptureService Optional capture implementation for session replay. + * @param customSessionId Optional session id to adopt instead of generating one, so this + * instance can share a single `session.id` with another LaunchDarkly + * SDK on the device. When null, a session id is generated automatically. */ fun init( application: Application, @@ -144,6 +147,7 @@ class LDObserve(private val client: Observe) : Observe { observability: ObservabilityOptions = ObservabilityOptions(), replay: ReplayOptions? = null, imageCaptureService: ImageCaptureServicing? = null, + customSessionId: String? = null, ) { val logger = ObserveLogger.build(observability.logAdapter, observability.loggerName, observability.debug) @@ -164,7 +168,7 @@ class LDObserve(private val client: Observe) : Observe { // NOTE: the calling thread must not hold any lock the main thread is waiting on, or // this will deadlock — see runOnMainThread KDoc. runOnMainThread { - installObservability(application, mobileKey, resource, logger, observability, obsContext) + installObservability(application, mobileKey, resource, logger, observability, obsContext, customSessionId) if (replay != null) { installSessionReplay( replay, @@ -189,9 +193,10 @@ class LDObserve(private val client: Observe) : Observe { logger: ObserveLogger, options: ObservabilityOptions, obsContext: ObservabilityContext, + customSessionId: String? = null, ) { val service = ObservabilityService( - application, mobileKey, resource, logger, options, + application, mobileKey, resource, logger, options, customSessionId, ) obsContext.sessionManager = service.sessionManager obsContext.userInteractionManager = service.userInteractionManager diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt new file mode 100644 index 0000000000..3ba2a22081 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/io/opentelemetry/android/LDRumSessionManagerAccessor.kt @@ -0,0 +1,17 @@ +package io.opentelemetry.android + +import io.opentelemetry.android.session.SessionManager + +/** + * Bridges to [OpenTelemetryRumBuilder.setSessionManager], which is package-private in + * OpenTelemetry Android. Declaring this object in the same `io.opentelemetry.android` package lets + * the LaunchDarkly SDK supply its own [SessionManager] (see + * [com.launchdarkly.observability.client.LDSessionManager]) so it backs the RUM SDK's `session.id` + * span/log appenders, rather than relying on the default manager captured via an instrumentation. + */ +internal object LDRumSessionManagerAccessor { + fun setSessionManager( + builder: OpenTelemetryRumBuilder, + sessionManager: SessionManager, + ): OpenTelemetryRumBuilder = builder.setSessionManager(sessionManager) +} From 50d39bd8940c98bd8ce87d8ba5d18b1ba155f7a4 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 09:11:02 -0700 Subject: [PATCH 11/21] feat(observability-android): disable auto rotation for external session id When a session id is supplied externally the caller owns the session lifecycle, matching iOS's isCustomSession. Skip both background-inactivity and max-lifetime rotation so the seeded id is used unchanged for the lifetime of the manager. Co-authored-by: Cursor --- .../observability/client/LDSessionManager.kt | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt index 35077df38c..38a378d862 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/LDSessionManager.kt @@ -18,7 +18,11 @@ import kotlin.time.Duration * [io.opentelemetry.android.LDRumSessionManagerAccessor] so it backs OpenTelemetry Android's own * `session.id` span/log appenders as well — making it the single source of session identity. * - * Rotation behavior mirrors OpenTelemetry Android's default `SessionManagerImpl` / + * When [initialSessionId] is supplied the session is treated as *custom*: the caller owns the + * session lifecycle, so automatic rotation is disabled and the seeded id is used for the lifetime + * of this manager (mirrors iOS's `isCustomSession`). + * + * Otherwise rotation mirrors OpenTelemetry Android's default `SessionManagerImpl` / * `SessionIdTimeoutHandler`: * - a foreground session never times out; * - a background gap of at least [backgroundInactivityTimeout] rotates the session on next use; @@ -27,8 +31,9 @@ import kotlin.time.Duration * Foreground/background transitions are fed in via [onApplicationForegrounded] / * [onApplicationBackgrounded] (wired from the service's app-lifecycle tracker). * - * @param initialSessionId Optional session id to start with. When null or blank, an id is generated - * lazily on first use (matching the default manager). + * @param initialSessionId Optional session id to start with. When non-blank the session is custom + * (never auto-rotated). When null or blank, an id is generated lazily on first use and rotated + * automatically (matching the default manager). * @param backgroundInactivityTimeout Background inactivity after which the session rotates. * @param maxLifetime Absolute maximum session lifetime before rotation. * @param clock Time source; defaults to the OpenTelemetry default clock. @@ -45,10 +50,17 @@ internal class LDSessionManager( private val lock = Any() private val observers = synchronizedList(ArrayList()) + /** + * Whether a session id was supplied externally. When true, the caller owns the session + * lifecycle, so automatic rotation (background-inactivity timeout and max-lifetime) is disabled + * and the seeded id is used for the lifetime of this manager. Mirrors iOS's `isCustomSession`. + */ + private val isCustomSession: Boolean = !initialSessionId.isNullOrBlank() + // Guarded by [lock]. private var session: Session = - if (!initialSessionId.isNullOrBlank()) { - Session.DefaultSession(initialSessionId, clock.now()) + if (isCustomSession) { + Session.DefaultSession(initialSessionId!!, clock.now()) } else { // Empty id + (-1) timestamp forces generation on first getSessionId(), exactly like // the default manager's initial Session.NONE. @@ -73,7 +85,8 @@ internal class LDSessionManager( synchronized(lock) { var candidate = session - if (sessionHasExpired() || hasTimedOut()) { + // An externally supplied session id is never rotated; the caller owns its lifecycle. + if (!isCustomSession && (sessionHasExpired() || hasTimedOut())) { candidate = Session.DefaultSession(idGenerator.generateSessionId(), clock.now()) } // Bump the inactivity timer after deciding, before notifying (a new span may be created). From d61873cac00f91ad548ae84f007d8386c7063706 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 09:44:50 -0700 Subject: [PATCH 12/21] chore(observability-android): drop unused activity instrumentation dependency The OpenTelemetry Android `activity` instrumentation is always suppressed (its app/activity lifecycle spans are superseded by our own), so the dependency was dead weight. Remove it (also drops `common-api`) and keep suppressInstrumentation("activity") as a defensive guard in case a host reintroduces it transitively. Co-authored-by: Cursor --- .../observability-android/lib/build.gradle.kts | 4 +++- .../observability/client/ObservabilityService.kt | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts index 9f31fa2fd1..a5a7b63d1e 100644 --- a/sdk/@launchdarkly/observability-android/lib/build.gradle.kts +++ b/sdk/@launchdarkly/observability-android/lib/build.gradle.kts @@ -92,7 +92,9 @@ dependencies { // OTEL Android Instrumentations implementation("io.opentelemetry.android.instrumentation:crash:0.11.0-alpha") - implementation("io.opentelemetry.android.instrumentation:activity:0.11.0-alpha") + // NOTE: the `activity` instrumentation is intentionally NOT depended on. It is superseded by + // LaunchDarkly's own app/screen lifecycle spans and would otherwise double-report; we also + // defensively suppress it by name (see ObservabilityService.createOtelRumConfig). // Use JUnit Jupiter for testing. // Testing exporters for telemetry inspection diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index 78a44f1fa1..806fdcecd6 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -422,12 +422,13 @@ class ObservabilityService( config.suppressInstrumentation("crash") } - // Always disable the OpenTelemetry Android activity instrumentation + // Defensively disable the OpenTelemetry Android activity instrumentation // ([io.opentelemetry.android.instrumentation.activity.ActivityLifecycleInstrumentation]). - // It emits an `AppStart` span plus per-activity lifecycle spans (`Created`, `Resumed`, - // `Paused`, `Stopped`, `Destroyed`, `Restarted`). These are now superseded by LaunchDarkly's - // own `app_launch`, `app_foreground`/`app_background`, and `screen_view` spans, so leaving - // it on would double-report the same app/screen lifecycle. + // We no longer depend on that artifact (see lib/build.gradle.kts), so it is normally not on + // the classpath; this suppression guards against it being reintroduced transitively by a + // host. It emits an `AppStart` span plus per-activity lifecycle spans, which are superseded + // by LaunchDarkly's own `app_launch`, `app_foreground`/`app_background`, and `screen_view` + // spans, so leaving it on would double-report the same app/screen lifecycle. config.suppressInstrumentation("activity") return config From ed34ca361b8669701f5aaf0bf36a109be4f760de Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 09:57:35 -0700 Subject: [PATCH 13/21] fix(observability-android): prime session manager with initial background state AppLifecycleTracker only reports genuine transitions (it suppresses the initial replay and never emits a catch-up background), so an SDK init while the app is already backgrounded left LDSessionManager stuck in FOREGROUND and skipped background-inactivity rotation until the next stop/start cycle. Query the process lifecycle at init and mark the manager backgrounded when appropriate; a later genuine onStart settles it back to foreground. Co-authored-by: Cursor --- .../observability/client/ObservabilityService.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt index 806fdcecd6..0146368f9f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/client/ObservabilityService.kt @@ -3,6 +3,8 @@ package com.launchdarkly.observability.client import android.app.Application import android.view.MotionEvent import android.view.ViewConfiguration +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import com.launchdarkly.observability.context.ObserveLogger import com.launchdarkly.observability.api.ObservabilityOptions import com.launchdarkly.observability.bridge.emitLog @@ -314,6 +316,16 @@ class ObservabilityService( // available; the span itself is gated by analytics.appLifecycle inside the handler. appLifecycleTracker.start() + // Prime the session manager with the actual process state. [appLifecycleTracker] only + // reports genuine foreground/background transitions (it suppresses the initial replay and + // never emits a catch-up background), so if the SDK initializes while the app is already + // backgrounded the manager would otherwise stay FOREGROUND and skip background-inactivity + // rotation until the next foreground/background cycle. A genuine onStart will later settle + // it back to foreground if the app comes forward. + if (!ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + ldSessionManager.onApplicationBackgrounded() + } + // App-launch detection runs unconditionally so the Session Replay `Launch` breadcrumb is // always available; the `app_launch` span is gated by analytics.appLaunch inside the handler. appLaunchTracker.start() From b475547256a711c4e7a5126b3e1fa5411f1a37ac Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 10:50:37 -0700 Subject: [PATCH 14/21] LDObserve track --- .../src/api/Observe.ts | 14 ++++++++ .../src/client/InstrumentationManager.ts | 34 +++++++++++++++++++ .../src/client/ObservabilityClient.ts | 9 +++++ .../src/sdk/LDObserve.ts | 4 +++ 4 files changed, 61 insertions(+) diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index b4ddbc2f99..f3cd7925a6 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts @@ -65,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 diff --git a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts index 6eb9d62e7e..948a14886e 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts @@ -6,6 +6,7 @@ import { Gauge, Histogram, Span as OtelSpan, + SpanKind, SpanOptions, trace, propagation, @@ -59,6 +60,9 @@ export type InstrumentationManagerOptions = Required & { projectId: string } +// Span name for custom track events, matching the iOS/Android SDKs (`track`). +const TRACK_SPAN_NAME = 'track' + export class InstrumentationManager { private traceProvider?: WebTracerProvider private loggerProvider?: LoggerProvider @@ -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) + } + } + public runWithHeaders( name: string, headers: Record, diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts index 1c8f1d2b4e..1d34c54dc7 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts @@ -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): RequestContext { const sessionInfo = this.sessionManager.getSessionInfo() return { diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index 862ec0898e..5b65532848 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -58,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): RequestContext { const response = this._bufferCall('parseHeaders', [headers]) return this._isLoaded From 1a92ad87aeecc9d32c8c16f98c0d8542a1eee66d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 11:00:37 -0700 Subject: [PATCH 15/21] utils --- .../src/instrumentation/utils.test.ts | 17 +++++++++++++++++ .../src/instrumentation/utils.ts | 13 +++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.test.ts b/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.test.ts index 09f70e9401..6fe25b55da 100644 --- a/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.test.ts +++ b/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.test.ts @@ -10,6 +10,23 @@ describe('getCorsUrlsPattern', () => { expect(getCorsUrlsPattern(false)).toEqual(/^$/) }) + it('treats string entries as literal substrings (escapes regex metachars)', () => { + const patterns = getCorsUrlsPattern([ + 'api.example.com', + ]) as RegExp[] + expect(patterns[0]).toEqual(/api\.example\.com/) + // The literal host matches. + expect(patterns[0].test('https://api.example.com/posts')).toBe(true) + // `.` must not act as a wildcard, so look-alike hosts do not match. + expect(patterns[0].test('https://apiXexampleYcom/posts')).toBe(false) + }) + + it('passes RegExp entries through unchanged', () => { + const re = /^https?:\/\/backend\./ + const patterns = getCorsUrlsPattern([re]) as RegExp[] + expect(patterns[0]).toBe(re) + }) + it('anchors the page host at the origin position', () => { vi.stubGlobal('window', { location: { host: 'example.com' }, diff --git a/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.ts b/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.ts index 66b9750ffb..a74c81ee6e 100644 --- a/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.ts +++ b/sdk/@launchdarkly/observability-shared/src/instrumentation/utils.ts @@ -87,6 +87,9 @@ export const shouldRecordRequest = ( ) } +const escapeRegExp = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Helper to convert tracingOrigins to a RegExp or array of RegExp export function getCorsUrlsPattern( tracingOrigins: TracingOrigins, @@ -98,17 +101,19 @@ export function getCorsUrlsPattern( } return patterns } else if (Array.isArray(tracingOrigins)) { + // Per the documented contract, string entries are literal substrings while RegExp entries + // are used as-is. Escape strings so characters like `.` are matched literally instead of as + // regex wildcards (e.g. `api.example.com` must not match `apiXexampleYcom`). return tracingOrigins.map((pattern) => - typeof pattern === 'string' ? new RegExp(pattern) : pattern, + typeof pattern === 'string' + ? new RegExp(escapeRegExp(pattern)) + : pattern, ) } return /^$/ // Match nothing if tracingOrigins is false or undefined } -const escapeRegExp = (s: string): string => - s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - // Build a regex that matches the page host (and its subdomains) at the origin // position of a URL, not anywhere inside it. Anchoring matters: an unanchored // /example.com/ also matches https://third-party.io/?store=example.com. From 2f643d2ecdc4b82970431bdd32245aa12776e9db Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Thu, 25 Jun 2026 11:48:03 -0700 Subject: [PATCH 16/21] feat(observability-react-native): accept plain nested dictionaries in track Reshape LDObserve.track properties from flat OpenTelemetry `Attributes` to a plain `TrackProperties` dictionary, matching the iOS (`[String: Any]`) and Android (`Map`) surfaces. A shared converter flattens the payload into attributes before recording the span: - nested objects -> dot-separated keys (e.g. `user.id`) - arrays of objects / mixed arrays -> indexed dotted keys (e.g. `products.0.price`) - homogeneous scalar arrays -> array attributes - null / undefined / unsupported values are skipped (never stringified) The LDClient.track afterTrack hook now flattens its data the same way (with reserved key/value taking precedence), so both track paths behave identically. Adds unit tests for the converter and updates the example app's API screen to exercise the plain (nested) variant. Co-authored-by: Cursor --- .../src/api/Observe.ts | 15 +++- .../src/api/TrackProperties.ts | 21 +++++ .../src/api/index.ts | 1 + .../src/client/InstrumentationManager.ts | 6 +- .../src/client/ObservabilityClient.ts | 3 +- .../src/plugin/observability.ts | 18 ++-- .../src/sdk/LDObserve.ts | 7 +- .../src/utils/trackAttributes.test.ts | 84 +++++++++++++++++++ .../src/utils/trackAttributes.ts | 71 ++++++++++++++++ .../example/src/ApiScreen.tsx | 72 +++++++++++++++- 10 files changed, 282 insertions(+), 16 deletions(-) create mode 100644 sdk/@launchdarkly/observability-react-native/src/api/TrackProperties.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts create mode 100644 sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index f3cd7925a6..c5d4e5b63d 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts @@ -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 { /** @@ -73,11 +74,21 @@ 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` 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. diff --git a/sdk/@launchdarkly/observability-react-native/src/api/TrackProperties.ts b/sdk/@launchdarkly/observability-react-native/src/api/TrackProperties.ts new file mode 100644 index 0000000000..96cf40b81f --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/api/TrackProperties.ts @@ -0,0 +1,21 @@ +/** + * A plain, loosely-typed property value accepted by {@link Observe.track}. + * + * This mirrors the `[String: Any]` (iOS) / `Map` (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 } diff --git a/sdk/@launchdarkly/observability-react-native/src/api/index.ts b/sdk/@launchdarkly/observability-react-native/src/api/index.ts index f47a26d9c3..03ad348124 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/index.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/index.ts @@ -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' diff --git a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts index 948a14886e..5f609e6fd7 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/InstrumentationManager.ts @@ -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, @@ -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 } : {}), diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts index 1d34c54dc7..586479cca0 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.ts @@ -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, @@ -169,7 +170,7 @@ export class ObservabilityClient { public track( key: string, - properties?: Attributes, + properties?: TrackProperties, metricValue?: number, ): void { if (this.options.disableTraces) return diff --git a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts index da3e71eac2..1a27354275 100644 --- a/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts +++ b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts @@ -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 { @@ -160,17 +162,21 @@ class TracingHook implements Hook { ...(hookContext.context ? getContextKeys(hookContext.context) : {}), + // 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 + ? 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 ? { value: hookContext.metricValue } : {}), - // Spread user-supplied track data last so it is attached as - // attributes. Non-primitive members are ignored by OpenTelemetry. - ...(typeof hookContext.data === 'object' && - hookContext.data !== null - ? (hookContext.data as Attributes) - : {}), } _LDObserve.startActiveSpan(LD_TRACK_SPAN_NAME, (span) => { diff --git a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts index 5b65532848..f6d7ca776d 100644 --- a/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts +++ b/sdk/@launchdarkly/observability-react-native/src/sdk/LDObserve.ts @@ -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' @@ -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]) } diff --git a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts new file mode 100644 index 0000000000..af1e32480a --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { flattenTrackProperties } from './trackAttributes' + +describe('flattenTrackProperties', () => { + it('returns an empty object for undefined', () => { + expect(flattenTrackProperties(undefined)).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('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({}) + }) +}) diff --git a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts new file mode 100644 index 0000000000..ff1869eb03 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts @@ -0,0 +1,71 @@ +import type { Attributes } from '@opentelemetry/api' +import type { TrackProperties } from '../api/TrackProperties' + +/** + * Flattens a plain {@link TrackProperties} dictionary into OpenTelemetry + * {@link Attributes}, mirroring the iOS / Android `track` converters so the same + * payloads behave consistently across platforms. + * + * OTel attributes are a flat scalar map, so structure is preserved where it can be: + * - scalars (string / number / boolean) become scalar attributes; + * - homogeneous scalar arrays become array attributes; + * - nested objects are flattened with dot-separated keys (e.g. `user.id`); + * - arrays of objects (or mixed-type arrays) are flattened with indexed dotted + * keys (e.g. `products.0.product_id`, `products.1.price`); + * - `null` / `undefined` and any other value (function, symbol, etc.) are + * skipped — never stringified. + */ +export function flattenTrackProperties( + properties?: TrackProperties, +): Attributes { + const out: Attributes = {} + if (properties) { + putObject(out, '', properties) + } + return out +} + +function putObject( + out: Attributes, + prefix: string, + source: { [key: string]: unknown }, +): void { + for (const [rawKey, value] of Object.entries(source)) { + const key = prefix ? `${prefix}.${rawKey}` : rawKey + putValue(out, key, value) + } +} + +function putValue(out: Attributes, key: string, value: unknown): void { + switch (typeof value) { + case 'string': + case 'boolean': + case 'number': + out[key] = value + return + case 'object': + if (value === null) return + if (Array.isArray(value)) { + putArray(out, key, value) + return + } + putObject(out, key, value as { [key: string]: unknown }) + return + default: + // undefined, function, symbol, bigint: skip; never stringify. + return + } +} + +function putArray(out: Attributes, key: string, list: unknown[]): void { + if (list.length === 0) return + if (list.every((e) => typeof e === 'string')) { + out[key] = list as string[] + } else if (list.every((e) => typeof e === 'boolean')) { + out[key] = list as boolean[] + } else if (list.every((e) => typeof e === 'number')) { + out[key] = list as number[] + } else { + list.forEach((element, index) => putValue(out, `${key}.${index}`, element)) + } +} diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx index f9dc9b994a..fa76f28e94 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example/src/ApiScreen.tsx @@ -17,10 +17,15 @@ import { context } from '@opentelemetry/api'; * exercising the public Observability + LaunchDarkly client APIs from React * Native. * - * The iOS sample has both `LDObserve.track` and `LDClient.track` variants. The - * React Native Observability API does not expose `track`, so every track recipe - * here goes through the LaunchDarkly client's `track` (the Observability plugin - * turns it into a span via its afterTrack hook). + * The iOS sample has both `LDObserve.track` and `LDClient.track` variants, and + * so does this screen. Both accept a plain dictionary (matching the native + * `[String: Any]` / `Map` surfaces): nested objects and arrays of + * objects are flattened into dotted attribute keys (e.g. `products.0.price`). + * - `LDObserve.track(...)` records a `track` span directly through the + * Observability API. + * - `ldClient.track(...)` goes through the LaunchDarkly client; the + * Observability plugin turns it into the same `track` span via its + * afterTrack hook. */ // Public endpoint used by the network-request recipe. @@ -156,6 +161,49 @@ export default function ApiScreen() { log('[log] recordLog(logs-button-pressed) with mixed attributes'); }; + // -- Track (via Observability API) --------------------------------------- + const trackViaLDObserve = () => { + // Records a `track` span directly through the Observability API, mirroring + // the iOS `LDObserve.shared.track(...)` demo. `properties` is a plain + // dictionary; the nested map is flattened to dotted keys (e.g. + // `test-map.test-string`). + LDObserve.track('track-via-ld-observe', { + 'test-string': 'react-native', + 'test-true': true, + 'test-false': false, + 'test-integer': 42, + // A 64-bit value beyond Int32 range (e.g. epoch nanoseconds). + 'test-long': 9_000_000_000_123, + 'test-double': 3.14, + 'test-map': { 'test-string': 'val' }, + }); + log('[track] LDObserve.track(track-via-ld-observe)'); + }; + + const trackNestedViaLDObserve = () => { + // A nested `track` payload via the plain-dictionary variant, mirroring the + // iOS `trackNested` demo. The `products` array of objects is flattened to + // `products.0.product_id`, `products.1.price`, etc. + LDObserve.track('checkout-started', { + 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 }, + ], + }); + log('[track] LDObserve.track(checkout-started) nested payload'); + }; + + const trackViaLDObserveWithMetric = () => { + // The optional third argument is the numeric metric value used by + // LaunchDarkly experimentation for numeric custom metrics. + LDObserve.track('purchase-completed', { currency: 'USD' }, 72.0); + log('[track] LDObserve.track(purchase-completed, value=72)'); + }; + // -- Track (via LaunchDarkly client) ------------------------------------- const trackViaLDClient = () => { ldClient.track('track-via-ld-client', { @@ -269,6 +317,22 @@ export default function ApiScreen() { /> + + + + + + + Date: Fri, 26 Jun 2026 14:30:45 -0700 Subject: [PATCH 17/21] add buttons --- .../src/utils/trackAttributes.test.ts | 85 +++++++++++++++++++ .../example-legacy/src/ApiScreen.tsx | 72 +++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts index af1e32480a..1a1ab44094 100644 --- a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts @@ -1,11 +1,17 @@ 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({ @@ -34,6 +40,34 @@ describe('flattenTrackProperties', () => { ).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({ @@ -81,4 +115,55 @@ describe('flattenTrackProperties', () => { 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, + }) + }) }) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx index 19db452837..9da2568d46 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/src/ApiScreen.tsx @@ -18,10 +18,15 @@ import {context} from '@opentelemetry/api'; * exercising the public Observability + LaunchDarkly client APIs from React * Native. * - * The iOS sample has both `LDObserve.track` and `LDClient.track` variants. The - * React Native Observability API does not expose `track`, so every track recipe - * here goes through the LaunchDarkly client's `track` (the Observability plugin - * turns it into a span via its afterTrack hook). + * The iOS sample has both `LDObserve.track` and `LDClient.track` variants, and + * so does this screen. Both accept a plain dictionary (matching the native + * `[String: Any]` / `Map` surfaces): nested objects and arrays of + * objects are flattened into dotted attribute keys (e.g. `products.0.price`). + * - `LDObserve.track(...)` records a `track` span directly through the + * Observability API. + * - `ldClient.track(...)` goes through the LaunchDarkly client; the + * Observability plugin turns it into the same `track` span via its + * afterTrack hook. */ export default function ApiScreen() { @@ -159,6 +164,49 @@ export default function ApiScreen() { log('[log] recordLog(logs-button-pressed) with mixed attributes'); }; + // -- Track (via Observability API) --------------------------------------- + const trackViaLDObserve = () => { + // Records a `track` span directly through the Observability API, mirroring + // the iOS `LDObserve.shared.track(...)` demo. `properties` is a plain + // dictionary; the nested map is flattened to dotted keys (e.g. + // `test-map.test-string`). + LDObserve.track('track-via-ld-observe', { + 'test-string': 'react-native', + 'test-true': true, + 'test-false': false, + 'test-integer': 42, + // A 64-bit value beyond Int32 range (e.g. epoch nanoseconds). + 'test-long': 9_000_000_000_123, + 'test-double': 3.14, + 'test-map': {'test-string': 'val'}, + }); + log('[track] LDObserve.track(track-via-ld-observe)'); + }; + + const trackNestedViaLDObserve = () => { + // A nested `track` payload via the plain-dictionary variant, mirroring the + // iOS `trackNested` demo. The `products` array of objects is flattened to + // `products.0.product_id`, `products.1.price`, etc. + LDObserve.track('checkout-started', { + 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}, + ], + }); + log('[track] LDObserve.track(checkout-started) nested payload'); + }; + + const trackViaLDObserveWithMetric = () => { + // The optional third argument is the numeric metric value used by + // LaunchDarkly experimentation for numeric custom metrics. + LDObserve.track('purchase-completed', {currency: 'USD'}, 72.0); + log('[track] LDObserve.track(purchase-completed, value=72)'); + }; + // -- Track (via LaunchDarkly client) ------------------------------------- const trackViaLDClient = () => { ldClient.track('track-via-ld-client', { @@ -272,6 +320,22 @@ export default function ApiScreen() { /> + + + + + + + Date: Fri, 26 Jun 2026 16:12:53 -0700 Subject: [PATCH 18/21] fix(observability-react-native): polyfill URL.origin for React Native React Native's built-in URL does not implement `.origin`, which the OpenTelemetry OTLP HTTP exporter/instrumentation requires. Without it every JS trace/log/track export throws "URL.origin is not implemented" and is silently dropped, regardless of Old/New Architecture (native spans are unaffected, which masked the issue). Apply react-native-url-polyfill at the package entry via setupURLPolyfill() so a spec-compliant URL is guaranteed before any exporter runs. Co-authored-by: Cursor --- .../observability-react-native/package.json | 3 ++- .../observability-react-native/src/index.ts | 10 ++++++++++ .../observability-react-native/vite.config.ts | 8 +++++++- yarn.lock | 12 ++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/package.json b/sdk/@launchdarkly/observability-react-native/package.json index dd63686082..d6e9e77e6d 100644 --- a/sdk/@launchdarkly/observability-react-native/package.json +++ b/sdk/@launchdarkly/observability-react-native/package.json @@ -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", diff --git a/sdk/@launchdarkly/observability-react-native/src/index.ts b/sdk/@launchdarkly/observability-react-native/src/index.ts index f6fc4c7104..b2e5640160 100644 --- a/sdk/@launchdarkly/observability-react-native/src/index.ts +++ b/sdk/@launchdarkly/observability-react-native/src/index.ts @@ -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' diff --git a/sdk/@launchdarkly/observability-react-native/vite.config.ts b/sdk/@launchdarkly/observability-react-native/vite.config.ts index 88f4d5ab1e..c828ce7e7e 100644 --- a/sdk/@launchdarkly/observability-react-native/vite.config.ts +++ b/sdk/@launchdarkly/observability-react-native/vite.config.ts @@ -20,7 +20,13 @@ export default defineConfig({ output: { exports: 'named', }, - external: ['@launchdarkly/react-native-client-sdk', 'react-native'], + external: [ + '@launchdarkly/react-native-client-sdk', + 'react-native', + // Resolved at runtime from the consumer app (transitive dep) so the + // polyfill shares a single global URL and isn't duplicated in the bundle. + /^react-native-url-polyfill/, + ], }, }, }) diff --git a/yarn.lock b/yarn.lock index 3c31466513..4bb6b20b73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9314,6 +9314,7 @@ __metadata: "@opentelemetry/semantic-conventions": "npm:^1.35.0" axios: "npm:^1.15.0" react-native: "npm:^0.79.0" + react-native-url-polyfill: "npm:^3.0.0" typedoc: "npm:^0.28.4" typescript: "npm:^5.0.4" vite: "npm:^6.4.3" @@ -41166,6 +41167,17 @@ __metadata: languageName: node linkType: hard +"react-native-url-polyfill@npm:^3.0.0": + version: 3.0.0 + resolution: "react-native-url-polyfill@npm:3.0.0" + dependencies: + whatwg-url-without-unicode: "npm:8.0.0-3" + peerDependencies: + react-native: "*" + checksum: 10/62457a360745bb65fda2349bb95d2a90ad70f87005aa464fa8dd4919fa34bf2007f64bf6bbb1a76b5cdb579e3df9da6a23040a0eddf64456cadf6e766069854f + languageName: node + linkType: hard + "react-native-web@npm:~0.19.13": version: 0.19.13 resolution: "react-native-web@npm:0.19.13" From 0efd9e6a36492a977b5b5feb4bf9edc44ae21c6c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 26 Jun 2026 16:14:18 -0700 Subject: [PATCH 19/21] style: apply prettier formatting Reformat track() signature and trackAttributes array handling to match Prettier, and restore a stray comment typo in the legacy example App. Co-authored-by: Cursor --- .../observability-react-native/src/api/Observe.ts | 6 +----- .../observability-react-native/src/utils/trackAttributes.ts | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index c5d4e5b63d..2e0c7bed3e 100644 --- a/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts +++ b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts @@ -84,11 +84,7 @@ export interface Observe { * @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?: TrackProperties, - metricValue?: number, - ): void + track(key: string, properties?: TrackProperties, metricValue?: number): void /** * Parse headers to extract request context. diff --git a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts index ff1869eb03..b407843d4a 100644 --- a/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts @@ -66,6 +66,8 @@ function putArray(out: Attributes, key: string, list: unknown[]): void { } else if (list.every((e) => typeof e === 'number')) { out[key] = list as number[] } else { - list.forEach((element, index) => putValue(out, `${key}.${index}`, element)) + list.forEach((element, index) => + putValue(out, `${key}.${index}`, element), + ) } } From 4b05738ef51d8e5c95ad87f1ce574f9defecb2d3 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 26 Jun 2026 16:16:08 -0700 Subject: [PATCH 20/21] chore(session-replay-react-native): reconcile 0.13.0 release Align package version, release-please manifest, CHANGELOG, and the legacy example Podfile.lock to 0.13.0 after merging main. Co-authored-by: Cursor --- .../example-legacy/ios/Podfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/ios/Podfile.lock b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/ios/Podfile.lock index f1ccf8994b..2d22574365 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/ios/Podfile.lock +++ b/sdk/@launchdarkly/react-native-ld-session-replay/example-legacy/ios/Podfile.lock @@ -1611,7 +1611,7 @@ PODS: - React-utils (= 0.78.0) - RNCAsyncStorage (2.2.0): - React-Core - - SessionReplayReactNative (0.11.1): + - SessionReplayReactNative (0.13.0): - DoubleConversion - glog - hermes-engine @@ -1936,7 +1936,7 @@ SPEC CHECKSUMS: ReactCodegen: 008c319179d681a6a00966edfc67fda68f9fbb2e ReactCommon: 0c097b53f03d6bf166edbcd0915da32f3015dd90 RNCAsyncStorage: b44e8a4e798c3e1f56bffccd0f591f674fb9198f - SessionReplayReactNative: 4386ca84c305214b2b8f770a6ad1865abc3d5d9d + SessionReplayReactNative: 2fa8f51d5389f579335f83fb8a922176535a530b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c From c88ac161880083e7aab02f72091bd7af82aedf8c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 26 Jun 2026 16:35:02 -0700 Subject: [PATCH 21/21] test(session-replay-react-native): mock react-native-url-polyfill in jest The observability-react-native entry now calls setupURLPolyfill() at import time, pulling in react-native-url-polyfill (ESM) plus its whatwg-url and RN NativeModule deps, which Jest cannot parse/load in the node test env. Map the package to a no-op stub since the polyfill is a pure runtime side-effect not exercised by these unit tests. Co-authored-by: Cursor --- .../jest/mocks/react-native-url-polyfill.js | 10 ++++++++++ .../react-native-ld-session-replay/package.json | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 sdk/@launchdarkly/react-native-ld-session-replay/jest/mocks/react-native-url-polyfill.js diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/jest/mocks/react-native-url-polyfill.js b/sdk/@launchdarkly/react-native-ld-session-replay/jest/mocks/react-native-url-polyfill.js new file mode 100644 index 0000000000..906e966d06 --- /dev/null +++ b/sdk/@launchdarkly/react-native-ld-session-replay/jest/mocks/react-native-url-polyfill.js @@ -0,0 +1,10 @@ +// `react-native-url-polyfill` ships ESM and pulls in `whatwg-url-without-unicode` +// plus React Native native modules (BlobModule), which Jest cannot load in the +// node test environment. The polyfill is a pure runtime side-effect (installing a +// spec-compliant global `URL` for the OTLP exporter), so a no-op stub is safe for +// unit tests that only exercise the session-replay plugin logic. +module.exports = { + setupURLPolyfill: () => {}, + URL: globalThis.URL, + URLSearchParams: globalThis.URLSearchParams, +}; diff --git a/sdk/@launchdarkly/react-native-ld-session-replay/package.json b/sdk/@launchdarkly/react-native-ld-session-replay/package.json index 6b504fc9da..fb7a7d8d1c 100644 --- a/sdk/@launchdarkly/react-native-ld-session-replay/package.json +++ b/sdk/@launchdarkly/react-native-ld-session-replay/package.json @@ -139,7 +139,10 @@ "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" - ] + ], + "moduleNameMapper": { + "^react-native-url-polyfill(/.*)?$": "/jest/mocks/react-native-url-polyfill.js" + } }, "commitlint": { "extends": [