Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions Sources/Terra/Terra+Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ extension Terra {
/// Legacy compatibility attribute during migration to keyed digests.
public static let safetySubjectSHA256 = "terra.safety.subject.sha256"
public static let anonymizationKeyID = "terra.anonymization.key_id"
public static let errorMessageLength = "terra.error.message.length"
public static let errorMessageHMACSHA256 = "terra.error.message.hmac_sha256"
/// Legacy compatibility attribute during migration to keyed digests.
public static let errorMessageSHA256 = "terra.error.message.sha256"

/// Marks spans created by auto-instrumentation (vs. manual `withInferenceSpan`).
public static let autoInstrumented = "terra.auto_instrumented"
Expand Down
104 changes: 75 additions & 29 deletions Sources/Terra/Terra+OpenTelemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ extension Terra {
enableLogs: Bool = false,
enableSignposts: Bool = true,
enableSessions: Bool = true,
otlpTracesEndpoint: URL = defaultOltpHttpTracesEndpoint(),
otlpTracesEndpoint: URL = defaultOtlpHttpTracesEndpoint(),
otlpMetricsEndpoint: URL = defaultOtlpHttpMetricsEndpoint(),
otlpLogsEndpoint: URL = defaultOltpHttpLoggingEndpoint(),
otlpLogsEndpoint: URL = defaultOtlpHttpLogsEndpoint(),
metricsExportInterval: TimeInterval = 60,
persistence: PersistenceConfiguration? = nil,
serviceName: String? = nil,
Expand Down Expand Up @@ -107,6 +107,33 @@ extension Terra {
.appendingPathComponent("terra", isDirectory: true)
}

public static func defaultOtlpHttpTracesEndpoint() -> URL {
URL(string: "http://localhost:4318/v1/traces")!
}

public static func defaultOtlpHttpMetricsEndpoint() -> URL {
URL(string: "http://localhost:4318/v1/metrics")!
}

public static func defaultOtlpHttpLogsEndpoint() -> URL {
URL(string: "http://localhost:4318/v1/logs")!
}

@available(*, deprecated, renamed: "defaultOtlpHttpTracesEndpoint")
public static func defaultOltpHttpTracesEndpoint() -> URL {
defaultOtlpHttpTracesEndpoint()
}

@available(*, deprecated, renamed: "defaultOtlpHttpMetricsEndpoint")
public static func defaultOltpHttpMetricsEndpoint() -> URL {
defaultOtlpHttpMetricsEndpoint()
}

@available(*, deprecated, renamed: "defaultOtlpHttpLogsEndpoint")
public static func defaultOltpHttpLoggingEndpoint() -> URL {
defaultOtlpHttpLogsEndpoint()
}

public enum InstallOpenTelemetryError: Error {
case alreadyInstalled
}
Expand All @@ -126,42 +153,48 @@ extension Terra {
public static func installOpenTelemetry(_ configuration: OpenTelemetryConfiguration) throws {
openTelemetryInstallLock.lock()
defer { openTelemetryInstallLock.unlock() }
let normalizedConfiguration = normalize(configuration)

if let installed = installedOpenTelemetryConfiguration {
if installed == configuration {
if installed == normalizedConfiguration {
return
}
throw InstallOpenTelemetryError.alreadyInstalled
}

installedOpenTelemetryConfiguration = configuration
let previousTracerProvider = OpenTelemetry.instance.tracerProvider
let previousMeterProvider = OpenTelemetry.instance.meterProvider
let previousLoggerProvider = OpenTelemetry.instance.loggerProvider

do {
if let persistence = configuration.persistence {
if let persistence = normalizedConfiguration.persistence {
try FileManager.default.createDirectory(at: persistence.storageURL, withIntermediateDirectories: true, attributes: nil)
}

let tracerProviderSdk = try installTracing(configuration: configuration)
let tracerProvider = try installTracing(configuration: normalizedConfiguration)

if configuration.enableSignposts {
if normalizedConfiguration.enableSignposts, let tracerProviderSdk = tracerProvider as? TracerProviderSdk {
installSignposts(tracerProviderSdk: tracerProviderSdk)
}

if configuration.enableLogs {
_ = try installLogs(configuration: configuration)
if normalizedConfiguration.enableLogs {
_ = try installLogs(configuration: normalizedConfiguration)
}

if configuration.enableSessions {
if normalizedConfiguration.enableSessions, let tracerProviderSdk = tracerProvider as? TracerProviderSdk {
tracerProviderSdk.addSpanProcessor(TerraSessionSpanProcessor())
SessionEventInstrumentation.install()
}

if configuration.enableMetrics {
let meterProvider = try installMetrics(configuration: configuration)
if normalizedConfiguration.enableMetrics {
let meterProvider = try installMetrics(configuration: normalizedConfiguration)
Terra.install(.init(privacy: Runtime.shared.privacy, meterProvider: meterProvider, registerProvidersAsGlobal: false))
}
installedOpenTelemetryConfiguration = normalizedConfiguration
} catch {
installedOpenTelemetryConfiguration = nil
OpenTelemetry.registerTracerProvider(tracerProvider: previousTracerProvider)
OpenTelemetry.registerMeterProvider(meterProvider: previousMeterProvider)
OpenTelemetry.registerLoggerProvider(loggerProvider: previousLoggerProvider)
throw error
}
}
Expand All @@ -179,7 +212,13 @@ extension Terra {
return Resource(attributes: attributes)
}

private static func installTracing(configuration: OpenTelemetryConfiguration) throws -> TracerProviderSdk {
private static func installTracing(configuration: OpenTelemetryConfiguration) throws -> any TracerProvider {
guard configuration.enableTraces else {
let provider = DefaultTracerProvider.instance
OpenTelemetry.registerTracerProvider(tracerProvider: provider)
return provider
}

func makeExporter() throws -> any SpanExporter {
let baseExporter = OtlpHttpTraceExporter(endpoint: configuration.otlpTracesEndpoint)
guard let persistence = configuration.persistence else {
Expand All @@ -193,18 +232,17 @@ extension Terra {
)
}

let spanProcessor: SpanProcessor?
if configuration.enableTraces {
let exporter = try makeExporter()
spanProcessor = SimpleSpanProcessor(spanExporter: exporter)
} else {
spanProcessor = nil
}
let exporter = try makeExporter()
let spanProcessor = SimpleSpanProcessor(spanExporter: exporter)
let resource = makeResource(configuration: configuration)
let sampler: Sampler?
if let ratio = configuration.traceSamplingRatio {
let clamped = min(max(ratio, 0), 1)
sampler = Samplers.parentBased(root: Samplers.traceIdRatio(ratio: clamped))
if ratio.isFinite {
let clamped = min(max(ratio, 0), 1)
sampler = Samplers.parentBased(root: Samplers.traceIdRatio(ratio: clamped))
} else {
sampler = nil
}
} else {
sampler = nil
}
Expand All @@ -217,9 +255,7 @@ extension Terra {
existing.updateActiveSampler(sampler)
}
existing.addSpanProcessor(TerraSpanEnrichmentProcessor())
if let spanProcessor {
existing.addSpanProcessor(spanProcessor)
}
existing.addSpanProcessor(spanProcessor)
return existing
}
fallthrough
Expand All @@ -230,9 +266,7 @@ extension Terra {
builder = builder.with(sampler: sampler)
}
builder = builder.add(spanProcessor: TerraSpanEnrichmentProcessor())
if let spanProcessor {
builder = builder.add(spanProcessor: spanProcessor)
}
builder = builder.add(spanProcessor: spanProcessor)
let provider = builder.build()
OpenTelemetry.registerTracerProvider(tracerProvider: provider)
return provider
Expand All @@ -253,6 +287,18 @@ extension Terra {
#endif
}

private static func normalize(_ configuration: OpenTelemetryConfiguration) -> OpenTelemetryConfiguration {
var normalized = configuration
if let ratio = normalized.traceSamplingRatio {
if ratio.isFinite {
normalized.traceSamplingRatio = min(max(ratio, 0), 1)
} else {
normalized.traceSamplingRatio = nil
}
}
return normalized
}

// MARK: - Metrics

private static func installMetrics(configuration: OpenTelemetryConfiguration) throws -> MeterProviderSdk {
Expand Down
17 changes: 8 additions & 9 deletions Sources/Terra/Terra+Runtime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,7 @@ final class Runtime {
}
}

if let meterProvider = installation.meterProvider {
metrics.configure(meterProvider: meterProvider)
}
metrics.configure(meterProvider: installation.meterProvider ?? OpenTelemetry.instance.meterProvider)
}

var privacy: Terra.Privacy {
Expand Down Expand Up @@ -206,8 +204,11 @@ private extension Runtime {
return Data(bytes)
}
#endif
let seed = UUID().uuidString + UUID().uuidString
return Data(Data(seed.utf8).prefix(anonymizationKeyLengthBytes))
var generator = SystemRandomNumberGenerator()
let randomBytes = (0..<anonymizationKeyLengthBytes).map { _ in
UInt8.random(in: UInt8.min...UInt8.max, using: &generator)
}
return Data(randomBytes)
}

static func readAnonymizationKeyFromKeychain() -> Data? {
Expand Down Expand Up @@ -274,8 +275,6 @@ final class TerraMetrics {
inferenceDurationMs = meter.histogramBuilder(name: Terra.MetricNames.inferenceDurationMs).build()
}

private static let emptyAttributes: [String: OpenTelemetryApi.AttributeValue] = [:]

func recordInference(durationMs: Double) {
// Copy references under the lock. OTel SDK instruments are thread-safe,
// so we release the lock before calling add/record to avoid holding it
Expand All @@ -286,7 +285,7 @@ final class TerraMetrics {
var inferenceDurationMs = inferenceDurationMs
lock.unlock()

inferenceCount?.add(value: 1, attributes: Self.emptyAttributes)
inferenceDurationMs?.record(value: durationMs, attributes: Self.emptyAttributes)
inferenceCount?.add(value: 1, attributes: [:])
inferenceDurationMs?.record(value: durationMs, attributes: [:])
}
}
35 changes: 33 additions & 2 deletions Sources/Terra/Terra+Scope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ extension Terra {
public func recordError(_ error: any Error, captureMessage: Bool = true) {
let message = String(describing: error)
let exceptionType = String(reflecting: type(of: error))
underlyingSpan.status = .error(description: captureMessage ? message : exceptionType)
underlyingSpan.status = .error(description: exceptionType)

var attributes: [String: AttributeValue] = [
"exception.type": .string(exceptionType),
]
if captureMessage {
attributes["exception.message"] = .string(message)
attributes.merge(redactedErrorAttributes(message: message, privacy: Runtime.shared.privacy)) { _, new in new }
}

underlyingSpan.addEvent(
Expand All @@ -51,3 +51,34 @@ extension Terra {
}
}
}

private func redactedErrorAttributes(message: String, privacy: Terra.Privacy) -> [String: AttributeValue] {
switch privacy.redaction {
case .drop:
return [:]
case .lengthOnly:
return [Terra.Keys.Terra.errorMessageLength: .int(message.count)]
case .hashHMACSHA256:
var attributes: [String: AttributeValue] = [
Terra.Keys.Terra.errorMessageLength: .int(message.count),
]
if Runtime.isHMACSHA256Available, let digest = Runtime.shared.hmacSHA256Hex(message) {
attributes[Terra.Keys.Terra.errorMessageHMACSHA256] = .string(digest)
if let keyID = Runtime.shared.anonymizationKeyIDValue {
attributes[Terra.Keys.Terra.anonymizationKeyID] = .string(keyID)
}
}
if privacy.emitLegacySHA256Attributes, Runtime.isSHA256Available, let legacyDigest = Runtime.sha256Hex(message) {
attributes[Terra.Keys.Terra.errorMessageSHA256] = .string(legacyDigest)
}
return attributes
case .hashSHA256:
var attributes: [String: AttributeValue] = [
Terra.Keys.Terra.errorMessageLength: .int(message.count),
]
if Runtime.isSHA256Available, let digest = Runtime.sha256Hex(message) {
attributes[Terra.Keys.Terra.errorMessageSHA256] = .string(digest)
}
return attributes
}
}
53 changes: 47 additions & 6 deletions Sources/TerraHTTPInstrument/AIRequestParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,19 @@ struct AIRequestParser {
let model = json["model"] as? String

let maxTokens: Int?
if let v = json["max_tokens"] as? Int {
if let v = intValue(json["max_tokens"]) {
maxTokens = v
} else if let v = json["max_completion_tokens"] as? Int {
} else if let v = intValue(json["max_completion_tokens"]) {
maxTokens = v
} else if let v = json["max_new_tokens"] as? Int {
} else if let v = intValue(json["max_new_tokens"]) {
maxTokens = v
} else {
maxTokens = nil
}

let temperature: Double?
if let v = json["temperature"] as? Double {
if let v = doubleValue(json["temperature"]) {
temperature = v
} else if let v = json["temperature"] as? Int {
temperature = Double(v)
} else {
temperature = nil
}
Expand All @@ -66,3 +64,46 @@ struct ParsedRequest: Sendable {
let temperature: Double?
let stream: Bool?
}

private func intValue(_ value: Any?) -> Int? {
guard let number = value as? NSNumber else {
return nil
}

if isBoolean(number) {
return nil
}

let doubleValue = number.doubleValue
guard doubleValue.isFinite else {
return nil
}

let rounded = doubleValue.rounded()
guard rounded == doubleValue else {
return nil
}

return Int(rounded)
}

private func doubleValue(_ value: Any?) -> Double? {
guard let number = value as? NSNumber else {
return nil
}

if isBoolean(number) {
return nil
}

let doubleValue = number.doubleValue
guard doubleValue.isFinite else {
return nil
}

return doubleValue
}

private func isBoolean(_ number: NSNumber) -> Bool {
CFGetTypeID(number) == CFBooleanGetTypeID()
}
Loading