zoobzio December 12, 2025 Edit this page

Concepts

The Bridge Model

Aperture bridges two systems:

Capitan - In-process event coordination. Signals, fields, observers.

OpenTelemetry - Vendor-neutral observability. Logs, metrics, traces.

┌─────────────────┐          ┌─────────────────┐
│     Capitan     │          │  OpenTelemetry  │
│                 │          │                 │
│  Signals        │──────────│  Logs           │
│  Fields         │  Aperture│  Metrics        │
│  Events         │──────────│  Traces         │
│                 │          │                 │
└─────────────────┘          └─────────────────┘

Configuration vs Providers

Two separate concerns:

Provider Configuration (Your Responsibility)

How OTEL talks to backends:

// Exporter: where data goes
exporter, _ := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint("jaeger:4318"))

// Provider: how it's processed
traceProvider := trace.NewTracerProvider(
    trace.WithSpanProcessor(trace.NewBatchSpanProcessor(exporter)),
    trace.WithSampler(trace.TraceIDRatioBased(0.1)),
    trace.WithResource(myResource),
)

Schema Configuration (Transformation Rules)

How events become signals:

schema := aperture.Schema{
    Metrics: []aperture.MetricSchema{...},
    Traces:  []aperture.TraceSchema{...},
    Logs:    &aperture.LogSchema{...},
}

Field Transformation

Capitan fields become OTEL attributes. This happens automatically for built-in types:

orderID := capitan.NewStringKey("order_id")
total := capitan.NewFloat64Key("total")

cap.Emit(ctx, sig, orderID.Field("ORD-123"), total.Field(99.99))

Becomes:

log.String("order_id", "ORD-123")
log.Float64("total", 99.99)

Custom Types

Custom types are automatically JSON serialized:

type OrderInfo struct {
    ID     string  `json:"id"`
    Total  float64 `json:"total"`
    Secret string  `json:"-"` // Excluded from JSON
}

orderKey := capitan.NewKey[OrderInfo]("order", "Order details")

cap.Emit(ctx, orderCreated, orderKey.Field(OrderInfo{
    ID:     "ORD-123",
    Total:  99.99,
    Secret: "internal",
}))
// Becomes: log.String("order", "{\"id\":\"ORD-123\",\"total\":99.99}")

Use standard JSON struct tags to control what gets exported.

Signal Types

Logs

Events become log records. By default, all events are logged. Use a whitelist to filter:

schema := aperture.Schema{
    Logs: &aperture.LogSchema{
        Whitelist: []string{"order.created", "order.failed"}, // Only these are logged
    },
}

Metrics

Signals trigger metric operations. Four instrument types:

TypeUse CaseValue Source
CounterCount occurrencesEvent emission
GaugeCurrent valueField value
HistogramDistributionField value
UpDownCounterBidirectional countField value
schema := aperture.Schema{
    Metrics: []aperture.MetricSchema{
        {Signal: "order.created", Name: "orders_total", Type: "counter"},
        {Signal: "system.cpu", Name: "cpu_percent", Type: "gauge", ValueKey: "percent"},
    },
}

Traces

Signal pairs become spans. Start and end events correlate by a key value:

schema := aperture.Schema{
    Traces: []aperture.TraceSchema{
        {
            Start:          "request.started",
            End:            "request.completed",
            CorrelationKey: "request_id",
            SpanName:       "http_request",
        },
    },
}

// Events correlate by request_id value
cap.Emit(ctx, reqStarted, requestID.Field("REQ-123"))
cap.Emit(ctx, reqCompleted, requestID.Field("REQ-123"))  // Completes span

Name-Based Matching

Schema configuration uses string names that match at runtime:

// Define signals with names
orderCreated := capitan.NewSignal("order.created", "Order created")
orderID := capitan.NewStringKey("order_id")

// Schema references by name
schema := aperture.Schema{
    Metrics: []aperture.MetricSchema{
        {Signal: "order.created", Name: "orders_total", Type: "counter"},
    },
    Traces: []aperture.TraceSchema{
        {
            Start:          "order.created",
            End:            "order.completed",
            CorrelationKey: "order_id",
        },
    },
}

// At runtime, event.Signal().Name() is compared to schema signal names

This enables hot-reload without recompilation.

Context Extraction

Pull values from context.Context into attributes:

type ctxKey string
const userIDKey ctxKey = "user_id"

// Register the context key
ap.RegisterContextKey("user_id", userIDKey)

// Reference by name in schema
schema := aperture.Schema{
    Context: &aperture.ContextSchema{
        Logs: []string{"user_id"},
    },
}
ap.Apply(schema)

ctx = context.WithValue(ctx, userIDKey, "user-123")
cap.Emit(ctx, sig)
// ^ Log includes user_id="user-123" attribute

Schema-Based Configuration

Load configuration from files instead of code:

# observability.yaml
metrics:
  - signal: order.created
    name: orders_total
    type: counter

logs:
  whitelist:
    - order.created
configBytes, _ := os.ReadFile("observability.yaml")
schema, _ := aperture.LoadSchemaFromYAML(configBytes)
ap.Apply(schema)

Enables hot-reload without recompilation.

Lifecycle

  1. Create providers - Configure OTEL exporters and processors
  2. Create aperture - Pass providers
  3. Apply schema - Configure transformation rules
  4. Emit events - Capitan events automatically transform
  5. Close aperture - Stop observing (does NOT shutdown providers)
  6. Shutdown providers - Your responsibility
// 1. Providers
logProvider := log.NewLoggerProvider(...)
defer logProvider.Shutdown(ctx)  // 6. Your shutdown

// 2. Aperture
ap, _ := aperture.New(cap, logProvider, meterProvider, traceProvider)
defer ap.Close()  // 5. Stop observing

// 3. Schema
ap.Apply(schema)

// 4. Events
cap.Emit(ctx, sig, fields...)

Next Steps