Achieving Full-Stack Trace Context Propagation from SwiftUI to a Go-Gin Backend and Webpack Frontend


The bug report was maddeningly vague: “Profile loading is slow on iOS.” The mobile team’s logs showed their URLSession task completed in 450ms, well within the SLA. Our Go-Gin backend team’s metrics showed a p99 latency of 35ms for the /v2/profile endpoint. Both teams were right, yet the user experience was poor. The 415ms gap was a ghost in the machine, a void of accountability between a native client and a containerized microservice. This is the classic scenario where isolated monitoring fails and distributed tracing becomes non-negotiable.

The initial goal was to stitch together a single trace view, starting from the user tapping a button in a SwiftUI view, flowing through our Go-Gin API, and ending in a Zipkin instance. This required propagating a trace context across process and technology boundaries—from Swift on an iPhone, through an HTTP request, into a Go runtime inside an OCI container.

The Backend Foundation: Instrumenting Go-Gin with OpenTelemetry

We started with the backend. It’s the central hub and the easiest to instrument. The choice was OpenTelemetry (OTel), as it decouples instrumentation from the tracing backend. Today we use Zipkin for its simplicity; tomorrow it might be Jaeger or a commercial APM. OTel ensures we won’t rewrite our instrumentation code.

Our Gin service is straightforward. The core challenge is injecting a middleware that correctly intercepts incoming HTTP requests, extracts any existing trace context (e.g., B3 headers), and starts a new span as a child of that context.

First, the setup for the tracer provider. This is boilerplate but critical. It configures the exporter (pointing to Zipkin), the service name, and the sampler. In a real-world project, you’d never use AlwaysSample. You’d use a TraceIDRatioBased sampler or a more sophisticated parent-based sampler.

// internal/trace/provider.go

package trace

import (
	"context"
	"log"
	"os"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/zipkin"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/resource"
	sdktrace "go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

// InitTracerProvider initializes and registers an OpenTelemetry TracerProvider.
// It returns a shutdown function that should be deferred in the main function.
func InitTracerProvider() (func(context.Context) error, error) {
	zipkinEndpoint := os.Getenv("ZIPKIN_ENDPOINT")
	if zipkinEndpoint == "" {
		zipkinEndpoint = "http://localhost:9411/api/v2/spans"
	}

	exporter, err := zipkin.New(
		zipkinEndpoint,
		// Using zipkin.WithLogger is crucial for debugging exporter issues.
		zipkin.WithLogger(log.New(os.Stderr, "zipkin-exporter", log.Ldate|log.Ltime|log.Lshortfile)),
	)
	if err != nil {
		return nil, err
	}

	// In a production system, you would want to configure the batcher with
	// more thought, potentially adjusting MaxQueueSize and BatchTimeout.
	bsp := sdktrace.NewBatchSpanProcessor(exporter)

	// We identify our service. This is what will appear in Zipkin.
	res, err := resource.Merge(
		resource.Default(),
		resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceName("go-gin-profile-service"),
			semconv.ServiceVersion("v1.0.2"),
		),
	)
	if err != nil {
		return nil, err
	}

	provider := sdktrace.NewTracerProvider(
		// A common mistake is to use AlwaysSample in production.
		// Start with TraceIDRatioBased and tune the ratio.
		sdktrace.WithSampler(sdktrace.AlwaysSample()),
		sdktrace.WithBatcher(bsp),
		sdktrace.WithResource(res),
	)

	otel.SetTracerProvider(provider)

	// B3 propagation is widely supported and used by default by many Zipkin instrumentations.
	// It's a good choice for interoperability.
	otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}, propagation.B3{}))

	// The returned shutdown function is important for graceful shutdown,
	// ensuring all buffered spans are sent to the exporter.
	return func(ctx context.Context) error {
		shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
		defer cancel()
		return provider.Shutdown(shutdownCtx)
	}, nil
}

With the provider configured, the Gin middleware is next. It leverages otelhttp which does the heavy lifting of extracting context and creating a server-side span. A common pitfall here is forgetting to name the span correctly. By default, it might just be the HTTP method. We need to set it to the route pattern (e.g., /v2/profile/:id) to ensure proper aggregation in the tracing UI.

// internal/middleware/tracing.go

package middleware

import (
	"fmt"

	"github.com/gin-gonic/gin"
	"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/trace"
)

// OpenTelemetryMiddleware sets up tracing for Gin routes.
func OpenTelemetryMiddleware() gin.HandlerFunc {
	// otelgin provides a convenient middleware that handles most of the boilerplate.
	return otelgin.Middleware("go-gin-profile-service",
		otelgin.WithSpanNameFormatter(func(c *gin.Context) string {
			// A good span name is crucial for observability.
			// Using the route path provides low-cardinality, meaningful names.
			// Avoid using high-cardinality values like specific IDs in the name.
			return fmt.Sprintf("HTTP %s %s", c.Request.Method, c.FullPath())
		}),
		otelgin.WithFilter(func(r *gin.Context) bool {
			// We don't want to trace health checks. It just adds noise.
			return r.Request.URL.Path != "/health"
		}),
	)
}

// AddSpanAttribute is a helper to add an attribute to the current span in the context.
func AddSpanAttribute(c *gin.Context, key string, value string) {
	span := trace.SpanFromContext(c.Request.Context())
	if span.IsRecording() {
		span.SetAttributes(attribute.String(key, value))
	}
}

// AddSpanEvent is a helper to add an event to the current span.
func AddSpanEvent(c *gin.Context, name string, attrs ...attribute.KeyValue) {
    span := trace.SpanFromContext(c.Request.Context())
    if span.IsRecording() {
        span.AddEvent(name, trace.WithAttributes(attrs...))
    }
}

Now, we integrate this into our main application. We also add a custom child span inside our handler to trace a specific business logic block, like a database call or another RPC. This demonstrates how to manually create spans within the request lifecycle.

// cmd/server/main.go

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"example.com/profile/internal/middleware"
	"example.com/profile/internal/trace"
	"github.com/gin-gonic/gin"
	"go.opentelemetry.io/otel"
)

func main() {
	ctx := context.Background()
	
	// Initialize the tracer provider. Defer the shutdown.
	shutdown, err := trace.InitTracerProvider()
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		if err := shutdown(ctx); err != nil {
			log.Printf("failed to shutdown tracer provider: %v", err)
		}
	}()

	router := gin.New()
	router.Use(gin.Recovery())
	router.Use(middleware.OpenTelemetryMiddleware())

	router.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	v2 := router.Group("/v2")
	{
		v2.GET("/profile/:id", getProfile)
	}

	srv := &http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	// Graceful shutdown handling
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %s\n", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit
	log.Println("Shutting down server...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		log.Fatal("Server forced to shutdown:", err)
	}
}

func getProfile(c *gin.Context) {
	// The middleware has already created a parent span for the request.
	// We can get the context from the request.
	ctx := c.Request.Context()
	
	// We get a tracer instance.
	tracer := otel.Tracer("profile.handler")
	
	// Create a child span to trace a specific operation.
	var span trace.Span
	ctx, span = tracer.Start(ctx, "fetchDataFromDB")
	defer span.End()

	profileID := c.Param("id")
	
	// Adding attributes to spans is vital for context.
	middleware.AddSpanAttribute(c, "profile.id", profileID)

	// Simulate database work
	time.Sleep(25 * time.Millisecond)
	middleware.AddSpanEvent(c, "DB query executed")

	span.End() // Explicitly end the DB span before doing more work

	// Simulate some data processing
	_, processingSpan := tracer.Start(ctx, "processProfileData")
	time.Sleep(10 * time.Millisecond)
	processingSpan.End()

	c.JSON(http.StatusOK, gin.H{"id": profileID, "name": "John Doe"})
}

Finally, we package this service into a minimal, OCI-compliant container using a multi-stage Dockerfile. The key is to build a static binary and copy it into a scratch or distroless image to minimize the attack surface and image size.

# Dockerfile

# ---- Builder Stage ----
FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .

# Build the binary with optimizations.
# CGO_ENABLED=0 is crucial for static linking.
# -ldflags="-w -s" strips debug information, reducing binary size.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /server ./cmd/server

# ---- Final Stage ----
FROM gcr.io/distroless/static-debian11

WORKDIR /
COPY --from=builder /server /server

# Expose port and define entrypoint.
EXPOSE 8080
ENTRYPOINT ["/server"]

A docker-compose.yml ties it all together for local development.

# docker-compose.yml
version: '3.8'

services:
  zipkin:
    image: openzipkin/zipkin:2
    ports:
      - "9411:9411"

  profile-service:
    build: .
    ports:
      - "8080:8080"
    environment:
      - ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
    depends_on:
      - zipkin

Running curl localhost:8080/v2/profile/123 and checking the Zipkin UI now shows a beautiful trace with a parent span for the HTTP request and child spans for the database and processing logic. The backend is solid.

The Mobile Client: Manually Propagating Context from SwiftUI

Instrumenting the iOS client was the real challenge. Unlike the backend, there isn’t a mature, all-encompassing OTel Swift SDK with automatic URLSession instrumentation. We had to build the context propagation mechanism ourselves. The principle is simple: before sending an HTTP request, create a client-side span and inject its context into the request headers using the B3 format.

We created a simple Tracing utility class to manage the tracer and provide helper methods. This is not a full OTel implementation but mimics the core concepts needed for context propagation.

// Tracing/Tracer.swift
import Foundation
import OpenTelemetryApi
import OpenTelemetrySdk
import ZipkinExporter

// A singleton to manage our tracing setup.
// In a real app, dependency injection is a better pattern.
class Tracer {
    static let shared = Tracer()
    
    private let tracerProvider: TracerProvider
    let otelTracer: OpenTelemetryApi.Tracer
    
    private init() {
        // A common mistake is to re-initialize the provider. It should be a singleton.
        self.tracerProvider = TracerProviderBuilder()
            .with(processor: SimpleSpanProcessor(spanExporter: ZipkinExporter(
                endpoint: URL(string: "http://localhost:9411/api/v2/spans")!
            )))
            .with(resource: Resource(attributes: [
                "service.name": AttributeValue.string("swiftui-profile-client"),
                "service.version": AttributeValue.string("1.1.0")
            ]))
            .build()
        
        self.otelTracer = tracerProvider.get(instrumentationName: "iOS.App.Tracer", instrumentationVersion: "1.0")
        
        // This is the critical part for cross-process propagation.
        OpenTelemetry.registerTracerProvider(tracerProvider: self.tracerProvider)
        OpenTelemetry.registerTextMapPropagator(propagator: B3Propagator())
    }
    
    func shutdown() {
        self.tracerProvider.shutdown()
    }
}

Now, the networking layer. We created a wrapper around URLSession to handle span creation and header injection. Every network call made through this service will automatically be part of a trace.

// Networking/NetworkService.swift
import Foundation
import OpenTelemetryApi

enum NetworkError: Error {
    case invalidURL
    case requestFailed(Error)
    case invalidResponse
}

class NetworkService {
    
    func fetchProfile(id: String) async throws -> [String: Any] {
        guard let url = URL(string: "http://localhost:8080/v2/profile/\(id)") else {
            throw NetworkError.invalidURL
        }
        
        var request = URLRequest(url: url)
        
        // This is where the magic happens. We create a span and inject its context.
        let span = Tracer.shared.otelTracer.spanBuilder(spanName: "HTTP GET /v2/profile/:id").startSpan()
        
        // Setting attributes on the client side gives us context about the device.
        span.setAttribute(key: "http.method", value: "GET")
        span.setAttribute(key: "net.peer.name", value: url.host ?? "unknown")
        span.setAttribute(key: "profile.id", value: id)
        
        // This is the propagation step. The B3Propagator takes the active
        // context (which now contains our new span) and writes it to the
        // request headers.
        let propagator = OpenTelemetry.instance.textMapPropagator
        let context = span.context
        
        var headers: [String: String] = [:]
        propagator.inject(context: context, carrier: &headers) { carrier, key, value in
            carrier?[key] = value
        }
        
        headers.forEach { key, value in
            request.addValue(value, forHTTPHeaderField: key)
        }
        
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            
            guard let httpResponse = response as? HTTPURLResponse else {
                span.setStatus(status: .error(description: "Invalid response type"))
                span.end()
                throw NetworkError.invalidResponse
            }
            
            span.setAttribute(key: "http.status_code", value: httpResponse.statusCode)
            if (200...299).contains(httpResponse.statusCode) {
                span.setStatus(status: .ok)
            } else {
                span.setStatus(status: .error(description: "HTTP Error: \(httpResponse.statusCode)"))
            }
            
            span.end()
            
            let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
            return json ?? [:]
            
        } catch {
            span.record(error: error)
            span.setStatus(status: .error(description: error.localizedDescription))
            span.end()
            throw NetworkError.requestFailed(error)
        }
    }
}

Finally, we call this from our SwiftUI view. The view itself shouldn’t know about tracing. It just calls a method on a view model. This keeps the concerns separate.

// UI/ProfileView.swift
import SwiftUI

class ProfileViewModel: ObservableObject {
    @Published var profileName: String = "Loading..."
    private let networkService = NetworkService()

    @MainActor
    func loadProfile() {
        // The root span for the user interaction.
        let rootSpan = Tracer.shared.otelTracer.spanBuilder(spanName: "DisplayProfileScreen").startSpan()
        
        Task {
            // The network call will create its own child span.
            // OTel automatically handles the parent-child relationship
            // as long as the work happens within the scope of the parent span.
            defer { rootSpan.end() }
            do {
                let profile = try await networkService.fetchProfile(id: "123")
                self.profileName = profile["name"] as? String ?? "Not Found"
                rootSpan.addEvent(name: "ProfileLoadedSuccessfully")
            } catch {
                self.profileName = "Failed to load profile."
                rootSpan.record(error: error)
            }
        }
    }
}

struct ProfileView: View {
    @StateObject private var viewModel = ProfileViewModel()
    
    var body: some View {
        VStack {
            Text(viewModel.profileName)
                .font(.largeTitle)
                .padding()
            
            Button("Reload Profile") {
                viewModel.loadProfile()
            }
        }
        .onAppear {
            viewModel.loadProfile()
        }
    }
}

Now, when we run the iOS app and tap the button, we see a complete trace in Zipkin. It starts with DisplayProfileScreen in the swiftui-profile-client service, has a child span for HTTP GET, which is then followed by the HTTP GET /v2/profile/:id span in the go-gin-profile-service, including its own children for database access. The 415ms ghost was identified: 380ms of it was unexplained latency on the mobile network, which was only visible when the client and server spans were stitched together.

Completing the Picture: The Webpack Admin Frontend

To round out our observability, we have an internal admin dashboard built with React and bundled with Webpack. This dashboard also calls the Go-Gin API. Tracing this flow is crucial for support staff diagnostics.

Instrumenting a web frontend with OTel is significantly easier than native mobile, thanks to mature libraries. The setup involves initializing a WebTracerProvider and registering instrumentations for fetch and XHR.

// src/tracing.js
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { ZipkinExporter } from '@opentelemetry/exporter-zipkin';
import { B3Propagator } from '@opentelemetry/propagator-b3';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const provider = new WebTracerProvider({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'webpack-admin-frontend',
  }),
});

provider.addSpanProcessor(new SimpleSpanProcessor(new ZipkinExporter({
  // The URL must be accessible from the user's browser.
  url: 'http://localhost:9411/api/v2/spans',
})));

// We use the ZoneContextManager to automatically propagate context in the browser.
provider.register({
  contextManager: new ZoneContextManager(),
  propagator: new B3Propagator(),
});

registerInstrumentations({
  instrumentations: [
    // The FetchInstrumentation will automatically create spans for `fetch` requests
    // and inject the B3 headers. This is the core of the web instrumentation.
    new FetchInstrumentation({
      // A common pitfall is CORS. The server MUST allow the B3 headers.
      // E.g., `x-b3-traceid`, `x-b3-spanid`, etc.
      propagateTraceHeaderCorsUrls: [
        /localhost:8080/
      ],
    }),
  ],
});

export const tracer = provider.getTracer('admin-frontend-tracer');

The Gin backend needs its CORS middleware updated to allow these headers. This is a frequent point of failure.

// In main.go, update the Gin router setup.
import "github.com/gin-contrib/cors"

// ...
router := gin.New()
router.Use(gin.Recovery())

// CORS configuration must be precise.
config := cors.DefaultConfig()
config.AllowOrigins = []string{"http://localhost:3000"} // Assuming webpack dev server runs here
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "x-b3-traceid", "x-b3-spanid", "x-b3-sampled", "x-b3-flags"}
router.Use(cors.New(config))

router.Use(middleware.OpenTelemetryMiddleware())
// ...

The React component can now make calls, and they will be traced automatically. We can also create custom spans for user interactions.

// src/AdminPanel.js
import React, { useState, useEffect } from 'react';
import { tracer } from './tracing';
import { trace } from '@opentelemetry/api';

function AdminPanel() {
  const [profile, setProfile] = useState(null);

  const fetchAdminProfile = () => {
    // Creating a custom span for a user interaction.
    const span = tracer.startSpan('FetchAdminProfileClick');
    
    // Use trace.setSpan to make this the active span for the duration of the callback.
    // Any automatic instrumentation (like fetch) will now create child spans of this span.
    trace.context().with(trace.setSpan(context.active(), span), () => {
      fetch('http://localhost:8080/v2/profile/456')
        .then(response => response.json())
        .then(data => {
          setProfile(data);
          span.addEvent('Profile data received');
          span.end();
        })
        .catch(error => {
          span.recordException(error);
          span.setStatus({ code: 2, message: error.message }); // 2 is ERROR
          span.end();
        });
    });
  };

  useEffect(() => {
    fetchAdminProfile();
  }, []);

  return (
    <div>
      <h1>Admin Profile Viewer</h1>
      <button onClick={fetchAdminProfile}>Reload</button>
      {profile && <pre>{JSON.stringify(profile, null, 2)}</pre>}
    </div>
  );
}

export default AdminPanel;

A webpack.config.js is standard, with nothing specific required for OTel beyond ensuring modern JS syntax is transpiled correctly. The key is to import and execute the tracing.js module as early as possible in the application’s entry point.

sequenceDiagram
    participant SwiftUI as SwiftUI Client
    participant GoGin as Go-Gin Backend
    participant Zipkin

    SwiftUI->>+GoGin: GET /v2/profile/123 (with B3 Headers)
    GoGin->>GoGin: Middleware creates server span
    GoGin->>GoGin: Handler creates DB child span
    GoGin-->>-SwiftUI: 200 OK
    
    par
        SwiftUI->>+Zipkin: Reports Client Spans
    and
        GoGin->>+Zipkin: Reports Server Spans
    end

The current implementation successfully traces requests across our entire stack, providing a unified view that immediately identifies cross-domain bottlenecks. However, it’s not production-ready. The AlwaysSample configuration would overwhelm any tracing backend at scale; a proper sampling strategy is the immediate next step. The manual context propagation in Swift is functional but brittle; it relies on developers remembering to use the NetworkService. A more robust solution might involve custom URLProtocol subclassing to intercept all URLSession traffic automatically. Furthermore, while traces are powerful, their value multiplies when correlated with structured logs by injecting the trace_id into every log entry, allowing a seamless pivot from a slow span to the exact logs generated during its execution.


  TOC