Skip to content

Repository files navigation

SignozSwift

Ergonomic OpenTelemetry wrapper for sending traces, logs, and metrics to SigNoz via OTLP/gRPC. One call to set up, top-level functions to instrument.

Installation

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/photon-hq/SignozSwift.git", from: "0.1.0"),
]

Then add "SignozSwift" to your target's dependencies:

.target(
    name: "MyApp",
    dependencies: ["SignozSwift"]
),

Quick Start

import SignozSwift

// Start instrumentation
Signoz.start(serviceName: "my-app") {
    $0.environment = "production"
    $0.serviceVersion = "1.0.0"
}
defer { Signoz.shutdown() }

// Trace
let result = try span("fetch-users", kind: .client) { s in
    s.setAttribute(key: "db.system", value: "postgresql")
    return try db.fetchUsers()
}

// Log
info("Request handled", attributes: ["status": 200])

Usage

Vapor HTTP Backend

For Vapor projects, depend on the SignozVapor product instead of SignozSwift — it re-exports everything plus the tracing middleware:

.target(
    name: "MyVaporApp",
    dependencies: [
        .product(name: "SignozVapor", package: "SignozSwift"),
    ]
),
import SignozVapor

func configure(_ app: Application) throws {
    Signoz.start(serviceName: "my-vapor-api") {
        $0.endpoint = "ingest.signoz.io:4317"       // or localhost:4317 for self-hosted
        $0.transportSecurity = .tls
        $0.headers = ["signoz-ingestion-key": "..."]
        $0.environment = "production"
        $0.serviceVersion = "1.0.0"
    }

    // Automatic request tracing — creates a .server span for every HTTP request
    // with OTel semantic convention attributes and W3C trace context propagation
    app.middleware.use(SignozTracingMiddleware())

    app.get("users") { req async throws -> [User] in
        // Spans created here are automatically nested under the request span
        try await db.fetchUsers()
    }
}

// In entrypoint:
defer { Signoz.shutdown() }

SignozTracingMiddleware sets http.method, http.target, http.scheme, http.status_code, and http.route on each span. Span names use the matched route pattern (e.g. GET /users/:id) when available. Vapor's HTTP metrics (http_requests_total, http_request_duration_seconds) are also automatically exported via the swift-metrics bridge.

ArgumentParser CLI

import ArgumentParser
import SignozSwift

@main
struct MyCLI: AsyncParsableCommand {
    @Option var input: String

    func run() async throws {
        Signoz.start(serviceName: "my-cli") {
            $0.spanProcessing = .simple  // flush immediately for short-lived CLI
        }
        defer { Signoz.shutdown() }

        try await span("process-data") { s in
            info("Starting", attributes: ["input": .string(input)])
            let result = try await processData(input)
            s.setAttribute(key: "records.processed", value: result.count)
        }
    }
}

iOS App

import SignozSwift
import SwiftUI

@main
struct MyApp: App {
    init() {
        Signoz.start(serviceName: "my-ios-app") {
            $0.endpoint = "ingest.signoz.io:4317"
            $0.transportSecurity = .tls
            $0.headers = ["signoz-ingestion-key": "..."]
            $0.environment = "production"
            $0.autoInstrumentation.signpostIntegration = true
            // urlSession + resourceDetection are ON by default
        }
    }

    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

API Reference

Setup & Lifecycle

// Start — serviceName is required, everything else has defaults
Signoz.start(serviceName: "my-app") { config in
    config.endpoint = "localhost:4317"  // default
    config.environment = "production"                 // deployment.environment
    config.hostName = .auto                             // host.name (short system hostname, domain suffix stripped)
    config.hostName = .custom("web-01")                 // host.name (explicit value)
    // config.hostName = .none                           // omit host.name (default)
    config.serviceVersion = "1.0.0"
    config.transportSecurity = .plaintext              // default
    config.spanProcessing = .batch()                  // default, use .simple for CLIs
    config.headers = ["signoz-ingestion-key": "..."]
    config.resourceAttributes = ["custom.attr": "value"]
    config.localPersistencePath = URL(filePath: "/tmp/signoz")  // optional on-disk queue
    config.consoleLog = .enabled                              // colored stderr output
}

// Shutdown — flush and clean up
Signoz.shutdown()

Tracing

// Sync span — auto-starts, auto-ends, auto-sets error status on throw
let result = try span("operation-name", kind: .client, attributes: [
    "http.method": "GET",
    "http.status_code": 200,
]) { s in
    s.setAttribute(key: "extra", value: "value")
    return try doWork()
}

// Async span
let data = try await span("fetch", kind: .client) { s async in
    try await URLSession.shared.data(from: url)
}

// Nested spans
span("parent") { _ in
    span("child") { _ in
        info("Inside child span")
    }
}

// Direct tracer access for advanced use
let s = Signoz.tracer.spanBuilder(spanName: "manual").startSpan()
s.end()

Logging

trace("Verbose detail")
debug("Debug info", attributes: ["count": 42])
info("Request handled", attributes: ["status": 200])
warn("Approaching limit", attributes: ["threshold": 0.9])
error("Something failed", attributes: ["code": 500])
fatal("Unrecoverable error")

// Direct logger access
Signoz.logger.log("Custom", severity: .info, attributes: ["key": "value"])

Console Output

By default (.auto), log calls also print colored output to stderr in DEBUG builds — useful during development without extra setup. In RELEASE builds, console output is automatically disabled to avoid noise.

Signoz.start(serviceName: "my-app") {
    $0.consoleLog = .enabled   // always print to stderr
    // $0.consoleLog = .disabled  // never print to stderr
    // $0.consoleLog = .auto      // DEBUG only (default)
}

info("Server started", attributes: ["port": 8080])
// stderr: [INFO] Server started {"port": 8080}

gRPC Auto-Tracing

If your app uses gRPC (via grpc-swift-2), you can automatically trace all RPC calls by attaching the bundled interceptors from grpc-swift-extras:

import SignozSwift
import GRPCNIOTransportHTTP2

// Client — auto-creates a span for each outgoing RPC
let client = GRPCClient(
    transport: try .http2NIOPosix(target: .dns(host: "api.example.com", port: 443)),
    interceptors: [
        ClientOTelTracingInterceptor(
            serverHostname: "api.example.com",
            networkTransportMethod: "tcp"
        )
    ]
)

// Server — auto-creates a span for each incoming RPC
let server = GRPCServer(
    transport: try .http2NIOPosix(address: .ipv4(host: "0.0.0.0", port: 8080)),
    services: [myService],
    interceptors: [
        ServerOTelTracingInterceptor(
            serverHostname: "api.example.com",
            networkTransportMethod: "tcp"
        )
    ]
)

Each span is annotated with OTel semantic conventions (rpc.system, rpc.service, rpc.method, rpc.grpc.status_code, etc.) and context is automatically propagated via W3C traceparent headers.

Attribute Literals

AttributeValue conforms to ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, and ExpressibleByBooleanLiteral, so you can write:

let attrs: [String: AttributeValue] = [
    "name": "alice",     // .string("alice")
    "count": 42,         // .int(42)
    "ratio": 0.75,       // .double(0.75)
    "enabled": true,     // .bool(true)
]

Configuration

Property Type Default Description
endpoint String "localhost:4317" gRPC endpoint (host:port)
serviceName String required Service name (service.name)
serviceVersion String "" Service version (service.version)
environment String "" Deployment environment (deployment.environment)
hostName .none | .auto | .custom(String) .none Host name (host.name). .none omits the attribute, .auto uses the short system hostname (strips domain suffixes like .local), .custom("...") uses an explicit value.
resourceAttributes [String: AttributeValue] [:] Extra resource attributes
headers [String: String] [:] gRPC metadata headers
transportSecurity .plaintext | .tls .plaintext Transport security mode
spanProcessing .simple | .batch(...) .batch() Span processing strategy
localPersistencePath URL? nil Directory for an on-disk telemetry queue. When set, traces, logs, and metrics are buffered to disk and forwarded to the OTLP collector from disk. Only records that reach the on-disk queue can be retained and replayed once connectivity resumes. Logs first enter an in-memory batch queue, which drops new records when full; records still in memory can be lost if the process exits without Signoz.shutdown(). The directory (and its per-signal traces/logs/metrics subdirectories) is created automatically and stays near-empty in normal operation — it accumulates files during an outage.
consoleLog .auto | .enabled | .disabled .auto Colored console output to stderr. .auto enables in DEBUG builds only, .enabled always prints, .disabled never prints.
autoInstrumentation AutoInstrumentation see below Auto-instrumentation toggles

Auto-Instrumentation

Property Default Description
urlSession true Auto-trace URLSession calls (Apple platforms)
resourceDetection true Auto-detect device/app/OS attributes (Apple platforms)
signpostIntegration false Bridge spans to Xcode Instruments (Apple platforms)
metricsShim true Bridge swift-metrics to OTel (enables Vapor HTTP metrics)

SigNoz Dashboard Mapping

Configuration Resource Attribute SigNoz Filter
serviceName service.name Services
environment deployment.environment Logs > Environment
hostName host.name Logs > Hostname

What's Under the Hood

SignozSwift wraps the official OpenTelemetry Swift SDK — it does not reinvent any OTel types.

All OTel types (Span, Tracer, Logger, AttributeValue, SpanKind, etc.) are re-exported via @_exported import OpenTelemetryApi, so you only need import SignozSwift.

Testing

Integration tests export telemetry via gRPC to localhost:4317. A local OpenTelemetry Collector must be running, otherwise each test will block waiting for gRPC timeouts (~60-240s per test).

Start the collector with Docker:

docker run -d --name otel-collector \
  -p 4317:4317 \
  -v $(pwd)/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml \
  otel/opentelemetry-collector-contrib:latest

The collector's debug exporter logs all received telemetry. One integration test reads docker logs to verify spans and logs were actually received end-to-end.

Then run tests:

swift test

Start/stop the collector between sessions:

docker start otel-collector
docker stop otel-collector

Requirements

  • Swift 6.2+
  • macOS 15+ / iOS 18+ / Linux

License

MIT

Background log export

Log export runs on OpenTelemetry's batch worker so a slow collector does not hold up the logging caller. The processor uses a 5-second schedule delay, a 2,048-record pending queue, batches of up to 512 records, and the existing 30-second export timeout. New records are dropped when the pending queue is full. Records retain their original timestamps, attributes, and trace context. With localPersistencePath enabled, logs reach disk only after the batch worker passes them to the persistence exporter. Records still in memory are not covered by disk persistence and can be lost if the process exits without flushing.

Call Signoz.shutdown() before exiting to flush queued logs. Shutdown can wait for exports; this change isolates normal log calls and does not establish an overall shutdown deadline. Console output and disk forwarding are unchanged.

About

Ergonomic OpenTelemetry wrapper for sending traces, logs, and metrics to SigNoz via OTLP/gRPC.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages