zoobzio December 12, 2025 Edit this page

Architecture

Component Overview

┌─────────────────────────────────────────────────────────────────┐
│                           Aperture                              │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                       Observer                            │  │
│  │  capitan.Observe() → receives all events                 │  │
│  └────────────────────────────┬─────────────────────────────┘  │
│                               │                                 │
│               ┌───────────────┼───────────────┐                 │
│               ▼               ▼               ▼                 │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────┐      │
│  │  Log Handler   │ │ Metric Handler │ │ Trace Handler  │      │
│  │                │ │                │ │                │      │
│  │  - Whitelist   │ │  - Counter     │ │  - Correlation │      │
│  │  - Transform   │ │  - Gauge       │ │  - Span start  │      │
│  │  - Context     │ │  - Histogram   │ │  - Span end    │      │
│  └────────┬───────┘ └───────┬────────┘ └───────┬────────┘      │
│           │                 │                   │                │
│           ▼                 ▼                   ▼                │
│  ┌────────────────┐ ┌────────────────┐ ┌────────────────┐      │
│  │  LogProvider   │ │ MeterProvider  │ │ TraceProvider  │      │
│  │  (from user)   │ │  (from user)   │ │  (from user)   │      │
│  └────────────────┘ └────────────────┘ └────────────────┘      │
└─────────────────────────────────────────────────────────────────┘

Event Flow

  1. Application emits capitan event
  2. Aperture's observer receives it
  3. Three handlers process in parallel:
    • Log handler: filters, transforms, emits
    • Metric handler: increments/records metrics
    • Trace handler: starts/ends spans based on correlation

Key Types

Aperture

The main entry point. Registers as a capitan observer and holds references to providers.

type Aperture struct {
    logProvider   log.LoggerProvider
    meterProvider metric.MeterProvider
    traceProvider trace.TracerProvider
    contextKeys   map[string]any  // name → context key for ctx.Value()
    config        config          // internal runtime config
    // ...
}

Schema

User-facing configuration format (YAML/JSON):

type Schema struct {
    Metrics []MetricSchema
    Traces  []TraceSchema
    Logs    *LogSchema
    Context *ContextSchema
    Stdout  bool
}

MetricSchema

Defines how a signal becomes a metric:

type MetricSchema struct {
    Signal      string  // Signal name to match
    Name        string  // OTEL metric name
    Type        string  // counter, gauge, histogram, updowncounter
    ValueKey    string  // Field name for value (non-counters)
    Description string
}

TraceSchema

Defines span correlation:

type TraceSchema struct {
    Start          string  // Signal name that starts span
    End            string  // Signal name that ends span
    CorrelationKey string  // String field name to match start/end
    SpanName       string
    SpanTimeout    string  // e.g., "5m", "30s"
}

Name-Based Matching

Schema uses string names that match at runtime via event.Signal().Name():

// Schema specifies signal by name
schema := Schema{
    Metrics: []MetricSchema{
        {Signal: "order.created", Name: "orders_total", Type: "counter"},
    },
}

// At runtime, handler compares:
if event.Signal().Name() == metricConfig.SignalName {
    // Increment counter
}

This enables hot-reload without recompilation.

Field Transformation

The transform component handles field-to-attribute conversion.

Built-in Types

Direct mapping for primitive types:

Capitan TypeOTEL Attribute
stringlog.String(key, value)
int, int32, int64log.Int64(key, value)
float32, float64log.Float64(key, value)
boollog.Bool(key, value)
time.Timelog.String(key, rfc3339)
time.Durationlog.Int64(key, millis)
[]bytelog.Bytes(key, value)
Custom typeslog.String(key, json)

Custom Type Handling

Custom types are automatically JSON serialized:

type OrderInfo struct {
    ID    string  `json:"id"`
    Total float64 `json:"total"`
}

// Becomes: log.String("order", "{\"id\":\"ORD-123\",\"total\":99.99}")

Use JSON struct tags to control serialization.

Trace Correlation

The trace handler maintains pending spans in a map keyed by composite key (signal name + correlation value):

type pendingSpan struct {
    ctx        context.Context
    span       trace.Span
    startTime  time.Time
    receivedAt time.Time
}

// Key format: "signalName:correlationValue"
pendingStarts map[string]*pendingSpan
pendingEnds   map[string]*pendingEnd

Flow

  1. Start signal arrives with correlation key value
  2. New span created, stored in pending map
  3. Cleanup goroutine monitors for timeouts
  4. End signal arrives with matching correlation value
  5. Span retrieved from pending map and ended

Out-of-Order Events

Aperture handles out-of-order delivery gracefully:

  1. If end arrives before start, end is stored in pendingEnds
  2. When start arrives, it checks pendingEnds first
  3. Span created with correct timestamps from both events

This works because capitan events capture timestamp at emission time, not delivery time.

Context Extraction

Context keys must be registered, then referenced by name in schema:

// Registration (requires actual Go type)
ap.RegisterContextKey("user_id", userIDKey)

// Schema references by name
schema := Schema{
    Context: &ContextSchema{
        Logs: []string{"user_id"},
    },
}

On each event:

for _, ctxKey := range config.ContextExtraction.Logs {
    if value := ctx.Value(ctxKey.Key); value != nil {
        attrs = append(attrs, transformContextValue(ctxKey.Name, value))
    }
}

Thread Safety

  • Aperture is safe for concurrent use
  • Observer callback runs on capitan's worker goroutine per signal
  • Pending span map uses mutex protection
  • Provider calls are delegated to OTEL (thread-safe by design)

Memory Management

  • No event buffering beyond capitan's internal buffers
  • Pending spans cleaned up on timeout
  • Metrics use OTEL's instrument pooling

Error Handling

  • Provider errors logged but not propagated (best-effort observability)
  • Invalid schemas rejected at Apply() time
  • Missing correlation keys logged as warnings
  • Transformation errors result in attribute omission, not failure

Diagnostic Signals

Aperture emits internal signals at DEBUG severity for operational visibility:

SignalWhen EmittedResolution
aperture:metric:value_missingGauge/histogram event lacks value fieldEnsure event includes the required value field
aperture:trace:correlation_missingTrace event lacks correlation fieldEnsure event includes the correlation field
aperture:trace:expiredSpan start/end never matched within timeoutCheck correlation IDs match, or increase timeout

Hot Reload

The Apply() method enables runtime configuration updates:

func (s *Aperture) Apply(schema Schema) error {
    // 1. Validate schema
    // 2. Build internal config
    // 3. Close old observer
    // 4. Create new observer with new config
}

Events during the swap may be dropped. For most applications this is acceptable.