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
- Application emits capitan event
- Aperture's observer receives it
- 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 Type | OTEL Attribute |
|---|---|
string | log.String(key, value) |
int, int32, int64 | log.Int64(key, value) |
float32, float64 | log.Float64(key, value) |
bool | log.Bool(key, value) |
time.Time | log.String(key, rfc3339) |
time.Duration | log.Int64(key, millis) |
[]byte | log.Bytes(key, value) |
| Custom types | log.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
- Start signal arrives with correlation key value
- New span created, stored in pending map
- Cleanup goroutine monitors for timeouts
- End signal arrives with matching correlation value
- Span retrieved from pending map and ended
Out-of-Order Events
Aperture handles out-of-order delivery gracefully:
- If end arrives before start, end is stored in
pendingEnds - When start arrives, it checks
pendingEndsfirst - 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:
| Signal | When Emitted | Resolution |
|---|---|---|
aperture:metric:value_missing | Gauge/histogram event lacks value field | Ensure event includes the required value field |
aperture:trace:correlation_missing | Trace event lacks correlation field | Ensure event includes the correlation field |
aperture:trace:expired | Span start/end never matched within timeout | Check 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.