diff --git a/.cirrus.yml b/.cirrus.yml index b4148f0..23f33f3 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -23,6 +23,7 @@ task: - source venv/bin/activate - pip install -r requirements.txt - pytest --verbose --junit-xml=pytest-junit.xml + - go test -v ./... pytest_junit_result_artifacts: path: "integration-tests/pytest-junit.xml" format: junit diff --git a/.editorconfig b/.editorconfig index 9c71277..0bc2f93 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,8 @@ root = true indent_style = space indent_size = 2 insert_final_newline = true + +[integration-tests/**] +indent_style = unset +indent_size = unset +insert_final_newline = unset diff --git a/Package.resolved b/Package.resolved index 0b9655d..3c6d57e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1", + "originHash" : "3a442857650816096c3d714a7dfa5847651ee6427bf098ae7fdbb6578509ddc6", "pins" : [ { "identity" : "antlr4", @@ -13,19 +13,19 @@ { "identity" : "cirruslabs_tart-guest-agent_apple_swift", "kind" : "remoteSourceControl", - "location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git", + "location" : "https://buf.build/gen/swift/git/1.33.3-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_apple_swift.git", "state" : { - "revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf", - "version" : "1.28.2-00000000000000-17d7dedafb88.1" + "revision" : "5c49a653f4b003161077d194bc708b7373628c99", + "version" : "1.33.3-20260114140118-bd09c26a260f.1" } }, { "identity" : "cirruslabs_tart-guest-agent_grpc_swift", "kind" : "remoteSourceControl", - "location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", + "location" : "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", "state" : { - "branch" : "1.24.2-00000000000000-17d7dedafb88.1", - "revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8" + "branch" : "main", + "revision" : "4935078c2fe2508360843596d71a1f844ce639a6" } }, { @@ -42,8 +42,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift.git", "state" : { - "revision" : "8c5e99d0255c373e0330730d191a3423c57373fb", - "version" : "1.24.2" + "revision" : "8f57f68b9d247fe3759fa9f18e1fe919911e6031", + "version" : "1.27.1" + } + }, + { + "identity" : "opentelemetry-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/open-telemetry/opentelemetry-swift", + "state" : { + "branch" : "main", + "revision" : "a9b620766cced177bfc4089bbc47860859550473" + } + }, + { + "identity" : "opentelemetry-swift-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/open-telemetry/opentelemetry-swift-core.git", + "state" : { + "revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f", + "version" : "2.3.0" + } + }, + { + "identity" : "opentracing-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/undefinedlabs/opentracing-objc", + "state" : { + "revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1", + "version" : "0.5.2" } }, { @@ -55,15 +82,6 @@ "version" : "0.1.0" } }, - { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa", - "state" : { - "revision" : "65b3d2a7608685e8d4a37c68fa2c64f28d0b537e", - "version" : "8.51.1" - } - }, { "identity" : "swift-algorithms", "kind" : "remoteSourceControl", @@ -87,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -123,8 +141,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "9cb486020ebf03bfa5b5df985387a14a98744537", - "version" : "1.6.1" + "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", + "version" : "1.9.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", + "version" : "2.7.1" } }, { @@ -132,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", - "version" : "2.83.0" + "revision" : "233f61bc2cfbb22d0edeb2594da27a20d2ce514e", + "version" : "2.93.0" } }, { @@ -186,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", - "version" : "1.28.2" + "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", + "version" : "1.33.3" } }, { @@ -261,6 +288,15 @@ "branch" : "master", "revision" : "e03289289155b4e7aa565e32862f9cb42140596a" } + }, + { + "identity" : "thrift-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/undefinedlabs/Thrift-Swift", + "state" : { + "revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e", + "version" : "1.1.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 69ce3cf..10cee5a 100644 --- a/Package.swift +++ b/Package.swift @@ -17,15 +17,17 @@ let package = Package( .package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"), - .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"), .package(url: "https://github.com/cfilipov/TextTable", branch: "master"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"), .package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"), .package(url: "https://github.com/groue/Semaphore", from: "0.0.8"), .package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"), .package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"), - .package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.24.2")), - .package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-17d7dedafb88.1"), + .package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.27.0")), + .package(url: "https://buf.build/gen/swift/git/1.27.1-20260114140118-bd09c26a260f.1/cirruslabs_tart-guest-agent_grpc_swift.git", branch: "main"), + .package(url: "https://github.com/open-telemetry/opentelemetry-swift", branch: "main"), + .package(url: "https://github.com/open-telemetry/opentelemetry-swift-core", from: "2.3.0"), + ], targets: [ .executableTarget(name: "tart", dependencies: [ @@ -35,7 +37,6 @@ let package = Package( .product(name: "SwiftDate", package: "SwiftDate"), .product(name: "Antlr4Static", package: "Antlr4"), .product(name: "Atomics", package: "swift-atomics"), - .product(name: "Sentry", package: "sentry-cocoa"), .product(name: "TextTable", package: "TextTable"), .product(name: "Sysctl", package: "swift-sysctl"), .product(name: "SwiftRadix", package: "SwiftRadix"), @@ -44,6 +45,9 @@ let package = Package( .product(name: "XAttr", package: "swift-xattr"), .product(name: "GRPC", package: "grpc-swift"), .product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"), + .product(name: "OpenTelemetryApi", package: "opentelemetry-swift-core"), + .product(name: "OpenTelemetrySdk", package: "opentelemetry-swift-core"), + .product(name: "OpenTelemetryProtocolExporterHTTP", package: "opentelemetry-swift"), ], exclude: [ "OCI/Reference/Makefile", "OCI/Reference/Reference.g4", diff --git a/Sources/tart/Commands/IP.swift b/Sources/tart/Commands/IP.swift index 51e74ab..ab142a2 100644 --- a/Sources/tart/Commands/IP.swift +++ b/Sources/tart/Commands/IP.swift @@ -2,7 +2,6 @@ import ArgumentParser import Foundation import Network import SystemConfiguration -import Sentry enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable { case dhcp, arp, agent diff --git a/Sources/tart/Commands/Prune.swift b/Sources/tart/Commands/Prune.swift index 68f9bfa..4ff12ff 100644 --- a/Sources/tart/Commands/Prune.swift +++ b/Sources/tart/Commands/Prune.swift @@ -1,6 +1,6 @@ import ArgumentParser import Dispatch -import Sentry +import OpenTelemetryApi import SwiftUI import SwiftDate @@ -109,9 +109,10 @@ struct Prune: AsyncParsableCommand { return } - SentrySDK.configureScope { scope in - scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune") - } + OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( + key: "prune.required-bytes", + value: .int(Int(requiredBytes)) + ) // Figure out how much disk space is available let attrs = try Config().tartCacheDir.resourceValues(forKeys: [ @@ -123,18 +124,14 @@ struct Prune: AsyncParsableCommand { UInt64(attrs.volumeAvailableCapacityForImportantUsage!) ) - SentrySDK.configureScope { scope in - scope.setContext(value: [ - "volumeAvailableCapacity": attrs.volumeAvailableCapacity!, - "volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!, - "volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated - ], key: "Prune") - } + OpenTelemetry.instance.contextProvider.activeSpan?.setAttributes([ + "prune.volume-available-capacity-bytes": .int(Int(attrs.volumeAvailableCapacity!)), + "prune.volume-available-capacity-for-important-usage-bytes": .int(Int(attrs.volumeAvailableCapacityForImportantUsage!)), + "prune.volume-available-capacity-calculated": .int(Int(volumeAvailableCapacityCalculated)), + ]) if volumeAvailableCapacityCalculated <= 0 { - SentrySDK.capture(message: "Zero volume capacity reported") { scope in - scope.setLevel(.warning) - } + OpenTelemetry.instance.contextProvider.activeSpan?.addEvent(name: "Zero volume capacity reported") return } @@ -149,8 +146,8 @@ struct Prune: AsyncParsableCommand { } private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws { - let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true) - defer { transaction.finish() } + let span = OTel.shared.tracer.spanBuilder(spanName: "prune").startSpan() + defer { span.end() } let prunableStorages: [PrunableStorage] = [try VMStorageOCI(), try IPSWCache()] let prunables: [Prunable] = try prunableStorages @@ -177,13 +174,17 @@ struct Prune: AsyncParsableCommand { continue } - try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path) + let allocatedSizeBytes = try prunable.allocatedSizeBytes() - cacheReclaimedBytes += try prunable.allocatedSizeBytes() + OpenTelemetry.instance.contextProvider.activeSpan? + .addEvent(name: "Pruned \(allocatedSizeBytes) bytes for \(prunable.url.path)") + + cacheReclaimedBytes += allocatedSizeBytes try prunable.delete() } - SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte); + OpenTelemetry.instance.contextProvider.activeSpan? + .addEvent(name: "Reclaimed \(cacheReclaimedBytes) bytes") } } diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index 8ca8f01..736916d 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -4,7 +4,7 @@ import Darwin import Dispatch import SwiftUI import Virtualization -import Sentry +import OpenTelemetryApi import System var vm: VM? @@ -525,14 +525,15 @@ struct Run: AsyncParsableCommand { try vncImpl.stop() } + OTel.shared.flush() Foundation.exit(0) } catch { - // Capture the error into Sentry - SentrySDK.capture(error: error) - SentrySDK.flush(timeout: 2.seconds.timeInterval) + // Capture the error into OpenTelemetry + OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error) fputs("\(error)\n", stderr) + OTel.shared.flush() Foundation.exit(1) } } @@ -566,12 +567,14 @@ struct Run: AsyncParsableCommand { } else { print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer")) + OTel.shared.flush() Foundation.exit(1) } #endif } catch (let e) { print(RuntimeError.SuspendFailed(e.localizedDescription)) + OTel.shared.flush() Foundation.exit(1) } } diff --git a/Sources/tart/OTel.swift b/Sources/tart/OTel.swift new file mode 100644 index 0000000..5b7d6a2 --- /dev/null +++ b/Sources/tart/OTel.swift @@ -0,0 +1,39 @@ +import Foundation +import OpenTelemetryApi +import OpenTelemetrySdk +import OpenTelemetryProtocolExporterHttp + +class OTel { + let spanExporter: SpanExporter + let spanProcessor: SpanProcessor + let tracerProvider: TracerProviderSdk + let tracer: Tracer + + static let shared = OTel() + + init() { + if let endpointRaw = ProcessInfo.processInfo.environment["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"], + let endpoint = URL(string: endpointRaw) { + spanExporter = OtlpHttpTraceExporter(endpoint: endpoint) + } else { + spanExporter = OtlpHttpTraceExporter() + } + spanProcessor = SimpleSpanProcessor(spanExporter: spanExporter) + tracerProvider = TracerProviderBuilder().add(spanProcessor: spanProcessor).build() + OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider) + + tracer = OpenTelemetry.instance.tracerProvider.get(instrumentationName: "tart", instrumentationVersion: CI.version) + } + + func flush() { + OpenTelemetry.instance.contextProvider.activeSpan?.end() + + tracerProvider.forceFlush() + + // Work around OpenTelemtry not flushing traces after explicitly asking it to do so + // + // [1]: https://github.com/open-telemetry/opentelemetry-swift/issues/685 + // [2]: https://github.com/open-telemetry/opentelemetry-swift/issues/555 + Thread.sleep(forTimeInterval: .fromMilliseconds(100)) + } +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 9d98a12..6114dde 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -1,7 +1,9 @@ import ArgumentParser import Darwin import Foundation -import Sentry +import OpenTelemetryApi +import OpenTelemetrySdk +import OpenTelemetryProtocolExporterHttp @main struct Root: AsyncParsableCommand { @@ -50,50 +52,27 @@ struct Root: AsyncParsableCommand { // Set line-buffered output for stdout setlinebuf(stdout) + defer { OTel.shared.flush() } + do { // Parse command var command = try parseAsRoot() - // Initialize Sentry - if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] { - SentrySDK.start { options in - options.dsn = dsn - options.releaseName = CI.release - options.tracesSampleRate = Float( - ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0" - ) as NSNumber? + // Create a root span for the command we're about to run + let span = OTel.shared.tracer.spanBuilder(spanName: type(of: command)._commandName).startSpan() + defer { span.end() } + OpenTelemetry.instance.contextProvider.setActiveSpan(span) - // By default only 5XX are captured - // Let's capture everything but 401 (unauthorized) - options.enableCaptureFailedRequests = true - options.failedRequestStatusCodes = [ - HttpStatusCodeRange(min: 400, max: 400), - HttpStatusCodeRange(min: 402, max: 599) - ] - - // https://github.com/cirruslabs/tart/issues/1163 - options.enableAppLaunchProfiling = false - options.configureProfiling = { - $0.profileAppStarts = false - } - } - - SentrySDK.configureScope { scope in - scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments") - } - - // Enrich future events with Cirrus CI-specific tags - if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] { - SentrySDK.configureScope { scope in - for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) { - scope.setTag(value: value, key: key) - } - } - } + // Enrich root command span with command's arguments + let commandLineArguments = ProcessInfo.processInfo.arguments.map { argument in + AttributeValue.string(argument) } - defer { - if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil { - SentrySDK.flush(timeout: 2.seconds.timeInterval) + span.setAttribute(key: "Command-line arguments", value: .array(AttributeArray(values: commandLineArguments))) + + // Enrich root command span with Cirrus CI-specific tags + if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] { + for (key, value) in tags.split(separator: ",").compactMap(splitEnvironmentVariable) { + span.setAttribute(key: key, value: .string(value)) } } @@ -115,19 +94,18 @@ struct Root: AsyncParsableCommand { } catch { // Not an error, just a custom exit code from "tart exec" if let execCustomExitCodeError = error as? ExecCustomExitCodeError { + OTel.shared.flush() Foundation.exit(execCustomExitCodeError.exitCode) } - // Capture the error into Sentry - if ProcessInfo.processInfo.environment["SENTRY_DSN"] != nil { - SentrySDK.capture(error: error) - SentrySDK.flush(timeout: 2.seconds.timeInterval) - } + // Capture the error into OpenTelemetry + OpenTelemetry.instance.contextProvider.activeSpan?.recordException(error) // Handle a non-ArgumentParser's exception that requires a specific exit code to be set if let errorWithExitCode = error as? HasExitCode { fputs("\(error)\n", stderr) + OTel.shared.flush() Foundation.exit(errorWithExitCode.exitCode) } @@ -136,7 +114,7 @@ struct Root: AsyncParsableCommand { } } - private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? { + private static func splitEnvironmentVariable(_ tag: String.SubSequence) -> (String, String)? { let splits = tag.split(separator: "=", maxSplits: 1) if splits.count != 2 { return nil diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index 6240853..54cac16 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -1,6 +1,6 @@ import Compression import Foundation -import Sentry +import OpenTelemetryApi enum OCIError: Error { case ShouldBeExactlyOneLayer @@ -43,7 +43,10 @@ extension VMDirectory { } let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +) - SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte) + OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( + key: "compressed_disk_size_bytes", + value: .int(Int(diskCompressedSize)) + ) let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0) defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...") diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 09ebc98..3e3eb58 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -169,14 +169,3 @@ extension RuntimeError : HasExitCode { } } } - -// Customize error description for Sentry[1] -// -// [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions -extension RuntimeError : CustomNSError { - var errorUserInfo: [String : Any] { - [ - NSDebugDescriptionErrorKey: description, - ] - } -} diff --git a/Sources/tart/VMStorageOCI.swift b/Sources/tart/VMStorageOCI.swift index a92b748..4240b4e 100644 --- a/Sources/tart/VMStorageOCI.swift +++ b/Sources/tart/VMStorageOCI.swift @@ -1,5 +1,5 @@ import Foundation -import Sentry +import OpenTelemetryApi import Retry class VMStorageOCI: PrunableStorage { @@ -145,9 +145,10 @@ class VMStorageOCI: PrunableStorage { } func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws { - SentrySDK.configureScope { scope in - scope.setContext(value: ["imageName": name.description], key: "OCI") - } + OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( + key: "oci.image-name", + value: .string(name.description) + ) defaultLogger.appendNewLine("pulling manifest...") @@ -181,7 +182,9 @@ class VMStorageOCI: PrunableStorage { } if !exists(digestName) { - let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true) + let span = OTel.shared.tracer.spanBuilder(spanName: "pull").setActive(true).startSpan() + defer { span.end() } + let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description) // Open an existing VM directory corresponding to this name, if any, @@ -194,9 +197,10 @@ class VMStorageOCI: PrunableStorage { // Try to reclaim some cache space if we know the VM size in advance if let uncompressedDiskSize = manifest.uncompressedDiskSize() { - SentrySDK.configureScope { scope in - scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI") - } + OpenTelemetry.instance.contextProvider.activeSpan?.setAttribute( + key: "oci.image-uncompressed-disk-size-bytes", + value: .int(Int(uncompressedDiskSize)) + ) let otherVMFilesSize: UInt64 = 128 * 1024 * 1024 @@ -229,9 +233,7 @@ class VMStorageOCI: PrunableStorage { return .throw } try move(digestName, from: tmpVMDir) - transaction.finish() }, onCancel: { - transaction.finish(status: SentrySpanStatus.cancelled) try? FileManager.default.removeItem(at: tmpVMDir.baseURL) }) } else { diff --git a/benchmark/go.mod b/benchmark/go.mod index 2d1e4e7..2d9cdbc 100644 --- a/benchmark/go.mod +++ b/benchmark/go.mod @@ -1,6 +1,7 @@ module github.com/cirruslabs/tart/benchmark -go 1.22.1 +go 1.23.0 + toolchain go1.24.1 require ( diff --git a/integration-tests/go.mod b/integration-tests/go.mod new file mode 100644 index 0000000..e9e0381 --- /dev/null +++ b/integration-tests/go.mod @@ -0,0 +1,24 @@ +module integration + +go 1.25 + +require ( + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/proto/otlp v1.9.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.78.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/integration-tests/go.sum b/integration-tests/go.sum new file mode 100644 index 0000000..b1439a0 --- /dev/null +++ b/integration-tests/go.sum @@ -0,0 +1,76 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5 h1:jP1RStw811EvUDzsUQ9oESqw2e4RqCjSAD9qIL8eMns= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.5/go.mod h1:WXNBZ64q3+ZUemCMXD9kYnr56H7CgZxDBHCVwstfl3s= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration-tests/opentelemetry_test.go b/integration-tests/opentelemetry_test.go new file mode 100644 index 0000000..bafa5e1 --- /dev/null +++ b/integration-tests/opentelemetry_test.go @@ -0,0 +1,94 @@ +package integration_test + +import ( + "compress/gzip" + "encoding/hex" + "integration/tart" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + tracepkg "go.opentelemetry.io/proto/otlp/collector/trace/v1" + "google.golang.org/protobuf/proto" +) + +func TestOpenTelemetry(t *testing.T) { + // Start a mock OpenTelemetry collector server + var traces []*tracepkg.ExportTraceServiceRequest + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var trace tracepkg.ExportTraceServiceRequest + + reader := request.Body + var err error + + if request.Header.Get("Content-Encoding") == "gzip" { + reader, err = gzip.NewReader(reader) + require.NoError(t, err) + } + + requestBytes, err := io.ReadAll(reader) + require.NoError(t, err) + + switch request.Header.Get("Content-Type") { + case "application/x-protobuf": + require.NoError(t, proto.Unmarshal(requestBytes, &trace)) + default: + require.FailNowf(t, "unsupported content type", + "we do not support %q yet", request.Header.Get("Content-Type")) + } + + traces = append(traces, &trace) + + var response tracepkg.ExportTraceServiceResponse + + responseBytes, err := proto.Marshal(&response) + require.NoError(t, err) + + writer.Header().Set("Content-Type", "application/x-protobuf") + _, err = writer.Write(responseBytes) + require.NoError(t, err) + })) + + // Start a "tart list" command + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", serverURL.JoinPath("v1/traces").String()) + t.Setenv("CIRRUS_SENTRY_TAGS", "A=B,C=D") + t.Setenv("TRACEPARENT", "00-00000000000000000000000000000001-0000000000000001-01") + + _, _, err = tart.Tart(t, "list") + require.NoError(t, err) + + // Ensure that the mock OpenTelemetry collector received a trace from "tart list" + require.Len(t, traces, 1) + + resourceSpans := traces[0].GetResourceSpans() + require.Len(t, resourceSpans, 1) + + scopeSpans := resourceSpans[0].GetScopeSpans() + require.Len(t, scopeSpans, 1) + + spans := scopeSpans[0].GetSpans() + require.Len(t, spans, 1) + + // Ensure that the root span is correctly named + span := spans[0] + require.Equal(t, "list", span.Name) + + // Ensure that CIRRUS_SENTRY_TAGS are propagated + stringAttributes := map[string]string{} + for _, attribute := range span.GetAttributes() { + stringAttributes[attribute.GetKey()] = attribute.GetValue().GetStringValue() + } + require.Equal(t, stringAttributes["A"], "B") + require.Equal(t, stringAttributes["C"], "D") + + // Ensure that W3C Trace Context is propagated + require.Equal(t, "00000000000000000000000000000001", hex.EncodeToString(span.GetTraceId())) + require.Equal(t, "0000000000000001", hex.EncodeToString(span.GetParentSpanId())) +} diff --git a/integration-tests/tart/tart.go b/integration-tests/tart/tart.go new file mode 100644 index 0000000..08469bc --- /dev/null +++ b/integration-tests/tart/tart.go @@ -0,0 +1,56 @@ +package tart + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + "testing" +) + +const tartCommandName = "tart" + +var ( + ErrTartNotFound = errors.New("tart command not found") + ErrTartFailed = errors.New("tart command returned non-zero exit code") +) + +func Tart(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + cmd := exec.CommandContext(t.Context(), tartCommandName, args...) + + var stdout, stderr bytes.Buffer + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", "", fmt.Errorf("%w: %s command not found in PATH, make sure Tart is installed", + ErrTartNotFound, tartCommandName) + } + + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + // Tart command failed, redefine the error to be the Tart-specific output + err = fmt.Errorf("%w: %q", ErrTartFailed, firstNonEmptyLine(stderr.String(), stdout.String())) + } + } + + return stdout.String(), stderr.String(), err +} + +func firstNonEmptyLine(outputs ...string) string { + for _, output := range outputs { + for _, line := range strings.Split(output, "\n") { + if line != "" { + return line + } + } + } + + return "" +}