Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/open-telemetry/opentelemetry-swift-core.git", exact: "2.3.0"),
.package(url: "https://github.com/launchdarkly/ios-client-sdk.git", exact: "10.0.0"),
.package(url: "https://github.com/launchdarkly/ios-client-sdk.git", exact: "10.1.0"),
.package(url: "https://github.com/kstenerud/KSCrash.git", from: "2.3.0"),
.package(url: "https://github.com/mw99/DataCompression", from: "3.8.0"),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.32.0"),
Expand Down
6 changes: 6 additions & 0 deletions Sources/Common/ApplicationAttributes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation
import UIKit

public class ApplicationProperties {
public static var name: String? = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
}
3 changes: 3 additions & 0 deletions Sources/LaunchDarklyObservability/API/Options.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ public struct Options {
public var serviceVersion: String
public var otlpEndpoint: String
public var backendUrl: String
public var contextFriendlyName: String?
public var resourceAttributes: [String: AttributeValue]
public var customHeaders: [String: String]
public var tracingOrigins: TracingOriginsOption
Expand All @@ -200,6 +201,7 @@ public struct Options {
serviceVersion: String = "0.1.0",
otlpEndpoint: String = "https://otel.observability.app.launchdarkly.com:4318",
backendUrl: String = "https://pub.observability.app.launchdarkly.com",
contextFriendlyName: String? = nil,
resourceAttributes: [String: AttributeValue] = [:],
customHeaders: [String: String] = [:],
tracingOrigins: TracingOriginsOption = .disabled,
Expand All @@ -219,6 +221,7 @@ public struct Options {
self.serviceVersion = serviceVersion
self.otlpEndpoint = otlpEndpoint
self.backendUrl = backendUrl
self.contextFriendlyName = contextFriendlyName
self.resourceAttributes = resourceAttributes
self.customHeaders = customHeaders
self.tracingOrigins = tracingOrigins
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
final class ObservabilityClient {
protocol InternalObserve: Observe {
var logClient: LogsApi { get }
}

final class ObservabilityClient: InternalObserve {
let logClient: LogsApi
private let tracer: TracesApi
private let logger: LogsApi
private let meter: MetricsApi
Expand All @@ -10,6 +15,7 @@ final class ObservabilityClient {
init(
tracer: TracesApi,
logger: LogsApi,
logClient: LogsApi,
meter: MetricsApi,
crashReportsApi: CrashReporting,
autoInstrumentation: [AutoInstrumentation],
Expand All @@ -18,6 +24,7 @@ final class ObservabilityClient {
) {
self.tracer = tracer
self.logger = logger
self.logClient = logClient
self.meter = meter
self.crashReportsApi = crashReportsApi
self.autoInstrumentation = autoInstrumentation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,24 @@ import OpenTelemetrySdk
import Common
#endif

public struct ObservabilityClientFactory {
public static func noOp() -> Observe {
struct ObservabilityClientFactory {
static func noOp() -> Observe {
return ObservabilityClient(
tracer: NoOpTracer(),
logger: NoOpLogger(),
logClient: NoOpLogger(),
meter: NoOpMeter(),
crashReportsApi: NoOpCrashReport(),
autoInstrumentation: [],
options: .init(),
context: nil
)
}
public static func instantiate(

static func instantiate(
withOptions options: Options,
mobileKey: String
) throws -> Observe {
) throws -> (InternalObserve) {
let appLifecycleManager = AppLifecycleManager()
let sessionManager = SessionManager(
options: .init(
Expand Down Expand Up @@ -207,6 +209,7 @@ public struct ObservabilityClientFactory {
return ObservabilityClient(
tracer: appTraceClient,
logger: appLogClient,
logClient: logClient,
meter: appMetricsClient,
crashReportsApi: crashReporting,
autoInstrumentation: autoInstrumentation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import OSLog

public final class Observability: Plugin {
private let options: Options
var observabilityService: InternalObserve?

public init(options: Options) {
self.options = options
Expand Down Expand Up @@ -36,6 +37,7 @@ public final class Observability: Plugin {
withOptions: options,
mobileKey: mobileKey
)
observabilityService = service
LDObserve.shared.client = service
LDObserve.shared.context = service.context
} catch {
Expand All @@ -44,6 +46,10 @@ public final class Observability: Plugin {
}

public func getHooks(metadata: EnvironmentMetadata) -> [any Hook] {
[EvalTracingHook(withSpans: true, withValue: true, version: options.serviceVersion, options: options)]
[ObservabilityHook(plugin: self,
withSpans: true,
withValue: true,
version: options.serviceVersion,
options: options)]
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Hook receives stale options without SDK metadata

The getHooks method passes self.options (the original immutable options) to ObservabilityHook, but Options is a struct. In register, line 19 creates a local copy with var options = options, modifies it with SDK metadata (version, project ID, service name, etc.), and uses that copy for ObservabilityClientFactory.instantiate. However, the modified copy is discarded when register returns. The hook's beforeEvaluation method uses options.resourceAttributes which will be missing all the SDK metadata that was added in register.

Additional Locations (1)

Fix in Cursor Fix in Web

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,30 @@ import LaunchDarkly
import Common
#endif

public final class EvalTracingHook: Hook {
final class ObservabilityHook: Hook {
private let queue = DispatchQueue(label: "com.launchdarkly.eval.tracing.hook")
private let plugin: Observability
private let withSpans: Bool
private let withValue: Bool
private let version: String
private let options: Options

public init(withSpans: Bool, withValue: Bool, version: String, options: Options) {
init(plugin: Observability,
withSpans: Bool,
withValue: Bool,
version: String,
options: Options) {
self.plugin = plugin
self.withSpans = withSpans
self.withValue = withValue
self.version = version
self.options = options
}

public func metadata() -> Metadata {
return Metadata(name: "Observability")
}

public func beforeEvaluation(
seriesContext: EvaluationSeriesContext,
seriesData: EvaluationSeriesData
Expand Down Expand Up @@ -77,9 +87,34 @@ public final class EvalTracingHook: Hook {
return seriesData
// }
}

public func afterIdentify(seriesContext: IdentifySeriesContext, seriesData: IdentifySeriesData, result: IdentifyResult) -> IdentifySeriesData {
guard case .complete = result else {
return seriesData
}

let context = seriesContext.context
var attributes = [String: AttributeValue]()
for (k, v) in context.contextKeys() {
attributes[k] = .string(v)
}

let canonicalKey = context.fullyQualifiedKey()
attributes["key"] = .string(options.contextFriendlyName ?? canonicalKey)
attributes["canonicalKey"] = .string(canonicalKey)
attributes[Self.IDENTIFY_RESULT_STATUS] = .string("completed")

plugin.observabilityService?.logClient.recordLog(
message: "LD.identify",
severity: .info,
attributes: attributes
)

return seriesData
}
}

extension EvalTracingHook {
extension ObservabilityHook {
static let PROVIDER_NAME: String = "LaunchDarkly"
static let HOOK_NAME: String = "LaunchDarkly Evaluation Tracing Hook"
static let INSTRUMENTATION_NAME: String = "com.launchdarkly.observability"
Expand All @@ -93,4 +128,5 @@ extension EvalTracingHook {
static let CUSTOM_FEATURE_FLAG_RESULT_REASON_IN_EXPERIMENT: String = "feature_flag.result.reason.inExperiment"
static let FEATURE_FLAG_SPAN_NAME = "evaluation" /// FEATURE_FLAG_SPAN_NAME
static let FEATURE_FLAG_CONTEXT_ATTR = "feature_flag.contextKeys"
static let IDENTIFY_RESULT_STATUS = "identify.result.status"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation
import LaunchDarklyObservability

struct IdentifyItemPayload: EventQueueItemPayload {
let attributes: [String: String]
var timestamp: TimeInterval

var exporterClass: AnyClass {
SessionReplayExporter.self
}

func cost() -> Int {
attributes.count * 100
}
}

extension IdentifyItemPayload {
// Using main thread to access to ldContext
@MainActor
init(options: Options, ldContext: LDContext? = nil, timestamp: TimeInterval) {
var attributes: [String: String] = options.resourceAttributes.compactMapValues {
switch $0 {
case .array, .set, .boolArray, .intArray, .doubleArray, .stringArray:
return nil
case .string(let v):
return v
case .bool(let v):
return v.description
case .int(let v):
return String(v)
case .double(let v):
return String(v)
}
}

var canonicalKey = ldContext?.fullyQualifiedKey() ?? "unknown"
var ldContextMap = ldContext?.contextKeys()
if let ldContextMap {
for (k, v) in ldContextMap {
attributes[k] = v
}
}

var contextFriendlyName: String? = nil
if let contextFriendlyNameUnwrapped = options.contextFriendlyName, contextFriendlyNameUnwrapped.isNotEmpty {
contextFriendlyName = contextFriendlyNameUnwrapped
} else if let ldContext, ldContext.isMulti() == true, let user = ldContextMap?["user"], !user.isEmpty {
contextFriendlyName = user
}
attributes["key"] = contextFriendlyName ?? canonicalKey
attributes["canonicalKey"] = canonicalKey

self.attributes = attributes
self.timestamp = timestamp
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
import LaunchDarklyObservability

struct ImageItemPayload: EventQueueItemPayload {
var exporterClass: AnyClass {
SessionReplayExporter.self
}

var timestamp: TimeInterval {
exportImage.timestamp
}

func cost() -> Int {
exportImage.data.count
}

let exportImage: ExportImage
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import OSLog
#endif

actor SessionReplayEventGenerator {
private var title: String
let padding = CGSize(width: 11, height: 11)
var sid = 0
var nextSid: Int {
Expand All @@ -27,10 +28,11 @@ actor SessionReplayEventGenerator {
var stats: SessionReplayStats?
let isDebug = false

init(log: OSLog) {
init(log: OSLog, title: String) {
if isDebug {
stats = SessionReplayStats(log: log)
self.stats = SessionReplayStats(log: log)
}
self.title = title
}

func generateEvents(items: [EventQueueItem]) -> [Event] {
Expand Down Expand Up @@ -73,7 +75,7 @@ actor SessionReplayEventGenerator {

func appendEvents(item: EventQueueItem, events: inout [Event]) {
switch item.payload {
case let payload as ScreenImageItem:
case let payload as ImageItemPayload:
let exportImage = payload.exportImage
guard lastExportImage != exportImage else {
break
Expand All @@ -98,6 +100,12 @@ actor SessionReplayEventGenerator {

case let interaction as TouchInteraction:
appendTouchInteraction(interaction: interaction, events: &events)

case let identifyItemPayload as IdentifyItemPayload:
if let event = identifyEvent(itemPayload: identifyItemPayload) {
events.append(event)
}

default:
break // Item wasn't needed for SessionReplay
}
Expand Down Expand Up @@ -178,14 +186,29 @@ actor SessionReplayEventGenerator {
}

func reloadEvent(timestamp: TimeInterval) -> Event {
let eventData = CustomEventData(tag: .reload, payload: "iOS Demo")
let eventData = CustomEventData(tag: .reload, payload: title)
let event = Event(type: .Custom,
data: AnyEventData(eventData),
timestamp: timestamp,
_sid: nextSid)
return event
}

func identifyEvent(itemPayload: IdentifyItemPayload) -> Event? {
// Encode attributes as a JSON string for the `user` field.
guard let data = try? JSONEncoder().encode(itemPayload.attributes),
let userJSONString = String(data: data, encoding: .utf8) else {
return nil
}

let eventData = CustomEventData(tag: .identify, payload: userJSONString)
let event = Event(type: .Custom,
data: AnyEventData(eventData),
timestamp: itemPayload.timestamp,
_sid: nextSid)
return event
}

func viewPortEvent(exportImage: ExportImage, timestamp: TimeInterval) -> Event {
#if os(iOS)
let currentOrientation = UIDevice.current.orientation.isLandscape ? 1 : 0
Expand Down Expand Up @@ -270,9 +293,3 @@ actor SessionReplayEventGenerator {
events.append(viewPortEvent(exportImage: exportImage, timestamp: timestamp))
}
}

extension ScreenImageItem: SessionReplayItemPayload {
func sessionReplayEvent() -> Event? {
return nil
}
}
Loading