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/api/Observe.ts b/sdk/@launchdarkly/observability-react-native/src/api/Observe.ts index f3cd7925a6..2e0c7bed3e 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,17 @@ export interface Observe { * `value` for LaunchDarkly numeric custom metrics, and any `properties` as * additional span attributes. * + * `properties` is a plain dictionary (like the native `[String: Any]` / + * `Map` 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/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/src/plugin/observability.ts b/sdk/@launchdarkly/observability-react-native/src/plugin/observability.ts index 868068a6ba..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,14 +162,16 @@ class TracingHook implements Hook { ...(hookContext.context ? getContextKeys(hookContext.context) : {}), - // Spread user-supplied track data first so the LaunchDarkly event - // `key` and metric `value` set below always win over any - // same-named properties in the payload. Non-primitive members are - // ignored by OpenTelemetry. + // Flatten user-supplied track data the same way LDObserve.track + // does, so nested objects/arrays survive as dotted attributes + // instead of being dropped by OpenTelemetry. ...(typeof hookContext.data === 'object' && hookContext.data !== null - ? (hookContext.data as Attributes) + ? flattenTrackProperties( + hookContext.data as TrackProperties, + ) : {}), + // Reserved fields are written last so caller data can't clobber them. key: hookContext.key, ...(hookContext.metricValue !== undefined && hookContext.metricValue !== null 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..1a1ab44094 --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from 'vitest' +import { flattenTrackProperties } from './trackAttributes' +import type { TrackProperties } from '../api/TrackProperties' + +describe('flattenTrackProperties', () => { + it('returns an empty object for undefined', () => { + expect(flattenTrackProperties(undefined)).toEqual({}) + }) + + it('returns an empty object for an empty payload', () => { + // Mirrors Swift `OtelAttributesTests.emptyPayload`. + expect(flattenTrackProperties({})).toEqual({}) + }) + + it('keeps scalar values as-is', () => { + expect( + flattenTrackProperties({ + str: 'hello', + num: 42, + float: 3.14, + yes: true, + no: false, + }), + ).toEqual({ + str: 'hello', + num: 42, + float: 3.14, + yes: true, + no: false, + }) + }) + + it('skips null and undefined values without stringifying', () => { + expect( + flattenTrackProperties({ + present: 'x', + missing: null, + absent: undefined, + }), + ).toEqual({ present: 'x' }) + }) + + it('drops dates and other unsupported values without stringifying', () => { + // Mirrors Swift `OtelAttributesTests.skipsArbitraryTypes`: values with no + // scalar/array/object attribute form (dates, functions, symbols, bigints) + // are dropped rather than coerced to a string. A Date has no own + // enumerable properties, so it contributes no flattened keys. + const date = new Date(0) + const result = flattenTrackProperties({ + keep: 1, + date, + fn: () => undefined, + sym: Symbol('s'), + big: 9n, + } as unknown as TrackProperties) + + expect(result).toEqual({ keep: 1 }) + expect(result.date).toBeUndefined() + expect(result.date).not.toBe(String(date)) + }) + + it('preserves 64-bit integers without truncation', () => { + // Mirrors Swift `OtelAttributesTests.preservesLong`. The value exceeds + // Int32 range but stays within JS's safe-integer range, so it round-trips + // without loss. + expect(flattenTrackProperties({ id: 9_000_000_000_123 })).toEqual({ + id: 9_000_000_000_123, + }) + }) + + it('flattens nested objects with dot-separated keys', () => { + expect( + flattenTrackProperties({ + user: { id: '7', tier: 'gold', prefs: { theme: 'dark' } }, + }), + ).toEqual({ + 'user.id': '7', + 'user.tier': 'gold', + 'user.prefs.theme': 'dark', + }) + }) + + it('keeps homogeneous scalar arrays as array attributes', () => { + expect( + flattenTrackProperties({ + tags: ['a', 'b'], + flags: [true, false], + sizes: [1, 2, 3], + }), + ).toEqual({ + tags: ['a', 'b'], + flags: [true, false], + sizes: [1, 2, 3], + }) + }) + + it('flattens arrays of objects with indexed dotted keys', () => { + expect( + flattenTrackProperties({ + products: [ + { product_id: 'SKU-1', quantity: 2, price: 24.0 }, + { product_id: 'SKU-2', quantity: 1, price: 24.0 }, + ], + }), + ).toEqual({ + 'products.0.product_id': 'SKU-1', + 'products.0.quantity': 2, + 'products.0.price': 24.0, + 'products.1.product_id': 'SKU-2', + 'products.1.quantity': 1, + 'products.1.price': 24.0, + }) + }) + + it('drops empty arrays', () => { + expect(flattenTrackProperties({ empty: [] })).toEqual({}) + }) + + it('converts a Segment "Product Added" flat payload', () => { + // Mirrors Swift `OtelAttributesTests.productAdded` (analytics-taxonomy + // ยง4.2): a flat e-commerce payload of scalars passes through unchanged. + expect( + flattenTrackProperties({ + name: 'Product Added', + product_id: 'SKU-1234', + quantity: 2, + price: 24.0, + currency: 'USD', + cart_id: 'cart_98f1', + }), + ).toEqual({ + name: 'Product Added', + product_id: 'SKU-1234', + quantity: 2, + price: 24.0, + currency: 'USD', + cart_id: 'cart_98f1', + }) + }) + + it('flattens a Segment "Checkout Started" nested products payload', () => { + // Mirrors Swift `OtelAttributesTests.checkoutStarted`. Swift nests the + // products as an array of AttributeSets; OTel JS has no nested set type, + // so RN flattens them into indexed dotted keys instead. + expect( + flattenTrackProperties({ + name: 'Checkout Started', + order_id: 'ord_5521', + value: 72.0, + currency: 'USD', + products: [ + { product_id: 'SKU-1234', quantity: 2, price: 24.0 }, + { product_id: 'SKU-9876', quantity: 1, price: 24.0 }, + ], + }), + ).toEqual({ + name: 'Checkout Started', + order_id: 'ord_5521', + value: 72.0, + currency: 'USD', + 'products.0.product_id': 'SKU-1234', + 'products.0.quantity': 2, + 'products.0.price': 24.0, + 'products.1.product_id': 'SKU-9876', + 'products.1.quantity': 1, + 'products.1.price': 24.0, + }) + }) +}) 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..b407843d4a --- /dev/null +++ b/sdk/@launchdarkly/observability-react-native/src/utils/trackAttributes.ts @@ -0,0 +1,73 @@ +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/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/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 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() { /> + + + + + + + ` 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() { /> + + + + + + + {}, + 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": [ 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"