Previous Chapter: Chapter 4 — Database Scalability: From MySQL to TiDB | Series Hub
Answer-First: Operating thousands of microservices generating billions of daily transactions makes naive logging (Elasticsearch/ELK) financially prohibitive and computationally unsustainable. Shopee adopted a next-generation observability stack: Rust-based Vector edge daemons parsing telemetry with SIMD acceleration, Apache Kafka buffering ingestion bursts, ClickHouse columnar storage compressing petabyte-scale logs by 12x with sparse indexing, OpenTelemetry (OTel) collectors executing tail-based adaptive sampling (retaining 100% of errors and p99 latency anomalies while discarding 99% of normal traces), and eBPF continuous profiling diagnosing production CPU/memory bottlenecks with sub-1% runtime overhead.
1. The Observability Trilemma at Hyper-Scale
At Shopee’s scale, observing distributed microservices during mega-campaigns introduces three conflicting pressures:
Cost & Storage Budget
▲
/ \
/ \
/ \
Data Completeness ◄───────► Query & Ingestion Latency
(100% Traces/Logs) (Sub-Second Incident Response)
- Storage Explosion: Over 500 TB of raw JSON logs generated daily quickly exhaust traditional inverted-index search clusters like Elasticsearch, driving astronomical SSD storage costs.
- Agent Resource Contention: Heavy logging sidecars (like Python or JVM-based log collectors) consume up to 15% of pod CPU and trigger memory out-of-memory (OOM) evictions during peak traffic.
- Sampling Dilemma: Head-based sampling (deciding whether to keep a trace at the initial HTTP ingress) blindfolds engineering teams from capturing elusive p99 tail latencies and distributed deadlocks that only surface deep within the call graph.
2. End-to-End Telemetry Architecture
Shopee’s unified observability platform decouples data collection, ingestion buffering, storage indexing, and visualization across dedicated high-throughput layers:
flowchart TD
subgraph ComputeNodes["Kubernetes Node Fleet"]
APP1["Go Microservice Pod"]
APP2["Payment Gateway Pod"]
VEC["Vector DaemonSet (Rust + SIMD Parser)"]
EBPF["eBPF Profiling Agent (Pyroscope / Beyla)"]
APP1 -->|stdout / JSON logs| VEC
APP2 -->|OTLP Traces / Metrics| VEC
APP1 -. kernel hooks .- EBPF
APP2 -. kernel hooks .- EBPF
end
subgraph StreamingBuffer["Message Ingestion Buffer"]
KAFKA_LOGS["Kafka: telemetry-logs-topic"]
KAFKA_TRACES["Kafka: telemetry-traces-topic"]
end
subgraph ProcessingLayer["Stream Workers & Tracing Collectors"]
OTEL_COL["OpenTelemetry Collector Fleet<br/>(Tail-Based Adaptive Sampler)"]
CLICK_SINK["ClickHouse Batch Ingestion Workers"]
end
subgraph StorageLayer["Analytical Long-Term Storage"]
CH["ClickHouse Columnar Warehouse<br/>(MergeTree + ZSTD Compression)"]
VM["VictoriaMetrics / M3DB<br/>(High-Cardinality Metrics TSDB)"]
PYRO["Pyroscope Storage<br/>(Continuous CPU & Memory Flamegraphs)"]
end
subgraph VisualizationLayer["Incident Triage & Dashboards"]
GRAFANA["Unified Grafana Dashboards"]
JAEGER["Jaeger Trace Exploration UI"]
end
VEC -->|Batch Push| KAFKA_LOGS
VEC -->|OTLP gRPC| KAFKA_TRACES
EBPF -->|Profiles| PYRO
KAFKA_LOGS --> CLICK_SINK --> CH
KAFKA_TRACES --> OTEL_COL --> JAEGER
OTEL_COL -->|Span Metrics| VM
CH --> GRAFANA
VM --> GRAFANA
PYRO --> GRAFANA
JAEGER --> GRAFANA
Key Architectural Decisions
- Rust-Powered Vector Edge Agents: Installed as a Kubernetes
DaemonSet, Vector reads container logs directly from node/var/log/pods/. By leveraging Rust SIMD vectorization, Vector parses JSON lines at over 400 MB/sec per CPU core, keeping CPU overhead below 0.5% per host. - Kafka Shock Absorber: Decouples ingestion spikes from database sinks. If ClickHouse undergoes a rolling cluster restart or compaction spike, Kafka retains hours of telemetry without dropping a single record.
- ClickHouse Columnar Warehouse: Replaces Elasticsearch for log analytics. By storing structured fields (e.g.,
service,level,trace_id,http_status) in dense columnar format with ZSTD-12 compression, ClickHouse reduces storage footprints by over 90% while executing multi-billion-row queries in hundreds of milliseconds.
3. High-Performance ClickHouse Log Storage Engine
To achieve real-time log querying across billions of rows, the log storage schema uses a specialized MergeTree engine partitioned by day with primary keys ordered for fast filtering:
-- Production ClickHouse Schema for Distributed Service Logs
CREATE TABLE service_logs (
timestamp DateTime64(3, 'UTC') CODEC(DoubleDelta, ZSTD(3)),
service LowCardinality(String),
environment LowCardinality(String),
level LowCardinality(String),
trace_id String CODEC(ZSTD(6)),
span_id String CODEC(ZSTD(6)),
http_method LowCardinality(String),
http_status UInt16,
duration_ms Float32 CODEC(Gorilla, ZSTD(3)),
message String CODEC(ZSTD(6)),
attributes Map(String, String) CODEC(ZSTD(6))
)
ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service, level, http_status, timestamp)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Schema Optimization Highlights:
LowCardinality(String): Encodes repetitive strings (likeservice,environment,level) as integer dictionaries, transforming string lookups into blazing-fast integer comparisons.- Compound Primary Index
(service, level, http_status, timestamp): Matches the natural triage workflow of engineers investigating production errors (WHERE service = 'order-service' AND level = 'ERROR'). DoubleDelta&GorillaCodecs: Specifically compress timestamps and floating-point execution latencies down to fractions of a byte per row.
4. Adaptive Tail-Based Distributed Tracing
In standard head-based sampling, a trace decision is made at the root gateway before the request finishes. If an internal database query takes 5 seconds or throws a 500 error deep inside the downstream inventory service, a head-sampled system configured at 1% sampling will drop 99% of those critical failure traces.
Shopee deploys OpenTelemetry Collector clusters with Tail-Based Sampling:
sequenceDiagram
autonumber
participant App as Microservice Fleet (Go)
participant Buffer as OTel Collector Memory Ring Buffer
participant Decision as Tail-Based Sampling Decision Engine
participant TraceSink as ClickHouse / Jaeger Storage
App->>Buffer: Push Span (trace_id=abc-1, duration=15ms, status=200)
App->>Buffer: Push Span (trace_id=abc-2, duration=820ms, status=504)
App->>Buffer: Push Span (trace_id=abc-3, duration=12ms, status=200)
Note over Buffer: Wait 5 seconds for all distributed spans to assemble
Buffer->>Decision: Submit Assembled Trace (abc-1)
Decision-->>Decision: Evaluate Rule: Status=200 & Latency < 100ms
Decision->>Buffer: Decision: Probabilistic 1% Filter -> DISCARD
Buffer->>Decision: Submit Assembled Trace (abc-2)
Decision-->>Decision: Evaluate Rule: Status >= 500 OR Latency > 500ms
Decision->>TraceSink: Decision: MATCH CRITICAL -> SAVE 100% (abc-2)
TraceSink-->>Decision: Persisted to Long-Term Storage
Tail-Based Sampling Rule Definition:
# OpenTelemetry Collector Tail-Based Sampling Processor
processors:
tail_sampling:
decision_wait: 5s # Wait for late-arriving asynchronous spans
num_traces: 250000 # Memory capacity for in-flight trace tracking
expected_new_traces_per_sec: 10000
policies:
# Rule 1: Retain 100% of all HTTP 5xx and internal gRPC errors
- name: capture-errors
type: status_code
status_code: { status_codes: [ ERROR ] }
# Rule 2: Retain 100% of slow requests exceeding P99 threshold (> 500ms)
- name: capture-latency-anomalies
type: latency
latency: { threshold_ms: 500 }
# Rule 3: Retain 100% of high-value VIP / checkout funnel operations
- name: capture-checkout-funnel
type: string_attribute
string_attribute:
key: http.target
values: [ "/api/v1/checkout", "/api/v1/payment" ]
# Rule 4: Sample remaining normal baseline 200 OK traffic at 1%
- name: probabilistic-sample-normal
type: probabilistic
probabilistic: { sampling_percentage: 1.0 }
5. Continuous Profiling with eBPF in Production Go Services
Traditional Go pprof profiling introduces CPU spikes and cannot be run continuously across thousands of production nodes. Shopee uses eBPF continuous profiling (Grafana Pyroscope / Beyla) to sample kernel stack traces and Go runtime goroutine scheduler states with sub-1% overhead.
// Package profiling initializes continuous flamegraph profiling with zero code intrusion.
package profiling
import (
"log"
"os"
"github.com/grafana/pyroscope-go"
)
// InitContinuousProfiling attaches non-blocking continuous profiling to the Go runtime.
func InitContinuousProfiling(serviceName string) (*pyroscope.Profiler, error) {
serverAddress := os.Getenv("PYROSCOPE_SERVER_ADDRESS")
if serverAddress == "" {
serverAddress = "http://pyroscope.telemetry.svc.cluster.local:4040"
}
profiler, err := pyroscope.Start(pyroscope.Config{
ApplicationName: serviceName,
ServerAddress: serverAddress,
Logger: pyroscope.StandardLogger,
Tags: map[string]string{
"env": os.Getenv("APP_ENV"),
"region": os.Getenv("K8S_REGION"),
"node_ip": os.Getenv("HOST_IP"),
},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU, // CPU hotspot flamegraph
pyroscope.ProfileAllocObjects, // Heap allocation counts (GC pressure)
pyroscope.ProfileAllocSpace, // Total allocated memory bytes
pyroscope.ProfileInuseObjects, // Active heap object count
pyroscope.ProfileGoroutines, // Goroutine leak detection
pyroscope.ProfileBlockCount, // Channel & mutex contention frequency
pyroscope.ProfileBlockDuration, // Lock waiting time
},
})
if err != nil {
return nil, err
}
log.Printf("Continuous profiling initialized for service: %s", serviceName)
return profiler, nil
}
By correlating continuous flamegraphs directly with distributed trace spans, engineers can click on a slow 500ms trace span and immediately see the exact line of code where a Go mutex lock contention occurred.
Frequently Asked Questions
How does ClickHouse maintain sub-second query latency when querying petabytes of logs?
ClickHouse achieves exceptional query performance through four architectural advantages:
- Columnar Data Layout: Only the columns referenced in the SQL
SELECTandWHEREclauses are read from disk, reducing I/O volume by over 95% compared to row-oriented stores. - Vectorized Execution: Leverages CPU SIMD (Single Instruction, Multiple Data) instructions to scan and filter tens of millions of rows per core every second.
- Sparse Indexing with Granularity 8192: Indexes one mark per 8,192 rows, allowing the query engine to skip entire physical data blocks rapidly with minimal index RAM footprint.
- Partition Pruning: Partitioning by date (
toYYYYMMDD) ensures queries specifying a time range immediately discard data parts outside the window without disk reads.
How does the OpenTelemetry Collector prevent memory exhaustion when running tail-based sampling during traffic surges?
Tail-based sampling requires buffering spans in memory until a trace completes. During flash sale surges, the collector manages memory safety through:
- Bounded Ring Buffer (
num_traceslimit): Enforces a strict ceiling on active in-memory traces (e.g., 250,000 traces). - Memory Ballast & Ballast Checkers: Detects heap allocation thresholds; if memory crosses 80% of pod limits, the sampler switches dynamically to early-drop or head-based shedding for low-priority endpoints.
- Cluster Routing with Trace-ID Hashing: An upstream Envoy or OTel load balancer hashes spans by
trace_idso that all spans belonging to the same trace land on the exact same collector instance, eliminating cross-node synchronization overhead.
How does continuous eBPF profiling differ from traditional Go pprof endpoints?
eBPF continuous profiling operates fundamentally differently from manual pprof:
- Kernel-Level Non-Intrusive Sampling: eBPF hooks directly into Linux kernel timer interrupts (
perf_events), reading call stacks from memory without pausing Go garbage collection (GC) or stopping the world. - System-Wide Correlation: Unlike pprof (which only observes the Go user-space runtime), eBPF profiles capture kernel syscalls, page faults, network TCP socket stalls, and CGo code execution.
- Always-On Historical Differential Analysis: Profiling runs 24/7 in production with <1% overhead, allowing developers to generate differential flamegraphs (“What changed in CPU consumption between 11:59 PM and 12:01 AM during 11.11?”).
Previous Chapter: Chapter 4 — Database Scalability: From MySQL to TiDB | Series Hub
