Integration patterns
How the OpenTelemetry Collector connects your apps to Orbtrace's Doris — the two field patterns, the per-signal Doris exporter wiring, recommended production settings, advanced tuning, and layouts for single hosts, Kubernetes, OpenShift and service meshes.
Orbtrace ships the product — the server, the UI, and the storage (Apache Doris). It does not ship an OpenTelemetry Collector. You run the Collector, and it writes your telemetry into Orbtrace's Doris.
This page is about how the Collector sits in your architecture. If you just want one service reporting in, start with Instrument your apps — that's the copy-paste path. Come back here when you're deciding the layout.
The mental model
Your applications
logs · traces · metrics
OpenTelemetry Collector
The standard upstream image — you run it, with a config we provide.
Orbtrace (the product)
Nothing Orbtrace-specific runs inside the Collector. It is the plain, unmodified contrib image plus a config file. There is no Orbtrace agent, plugin, or SDK to install.
Three facts that make the rest of this page easy:
- The Collector is
otel/opentelemetry-collector-contrib. Not a fork, not our build. The only requirement is the contrib distribution, because the Doris exporter ships only there. - The exporter is contrib's own
dorisexporter. It writes batches into Doris over Stream Load, Doris's high-throughput HTTP load API. - Orbtrace owns the database schema, not the Collector. Orbtrace creates and migrates every table when it starts. The exporter must therefore run with
create_schema: false— more on this below, because getting it wrong is the single most common mistake.
Which pattern fits you?
| You have… | Use | In one line |
|---|---|---|
| No Collector yet | Pattern A | Run one container. Done in a minute. |
| An existing OTel Collector or pipeline | Pattern B | Add one exporter. Don't re-platform. |
Both start from the same reference config that ships in your deploy bundle at compose/otelcol-config.yaml. It already contains the batching, queueing, sampling, and per-signal Doris wiring described on this page — copy from it rather than typing config by hand.
Pattern A — one Collector, greenfield
- 1
Get the config file
The Collector needs a config file —
otelcol-config.yaml. Don't write it from scratch: it ships ready-made in your Orbtrace deploy bundle, in thecompose/directory, with all the batching, queueing, sampling, and per-signal Doris wiring on this page already in place. The bundle is a free public download.Download the bundle, unpack it, and
cdinto itscompose/directory — that's whereotelcol-config.yamllives. - 2
Run the Collector
From that
compose/directory, start the Collector container next to the Orbtrace stack:docker run -d --name orbtrace-otelcol \ --network orbtrace-net \ -p 4317:4317 -p 4318:4318 \ -v "$PWD/otelcol-config.yaml:/etc/otelcol-contrib/config.yaml" \ --tmpfs /var/lib/otelcol/queue:rw,mode=1777 \ otel/opentelemetry-collector-contrib:latest - 3
Point your apps at it
Set every service's
OTEL_EXPORTER_OTLP_ENDPOINTto this Collector —http://orbtrace-host:4317. Your service appears in Orbtrace within seconds. Per-language SDK setup is on Instrument your apps.
The `--tmpfs` line is for evaluation only
It gives the Collector an in-memory scratch area for its on-disk send queue. Simple, but wiped on restart — a Doris outage that outlives a Collector restart loses the buffered batches. For production, mount a real volume the Collector can write to (it runs as UID 10001): a chown 10001 bind mount, or on Kubernetes a PVC with securityContext.fsGroup: 10001.
TLS
The example exposes plain gRPC on :4317. If the Collector is reachable beyond a trusted network, terminate TLS in front of it with a reverse proxy (Caddy or Nginx).
Pattern B — add Orbtrace to a pipeline you already run
You already have a Collector, or a vendor agent that speaks OTLP. Don't replace it. Add Orbtrace as a second destination, so the same telemetry also lands in Doris. Your existing backend keeps receiving everything; Orbtrace gets a copy. This is the clean way to trial Orbtrace, or to migrate off another vendor by running both side by side.
Two hard requirements:
- Your Collector must be the contrib build. The
dorisexporter ships only inotel/opentelemetry-collector-contrib. - Every Doris exporter must run with
create_schema: false. Orbtrace already created the tables; a schema-creating exporter races the Orbtrace migrator and splits the schema.
Use one exporter per signal — not one shared exporter
It is tempting to declare a single doris: exporter and list it under traces, logs and metrics. Don't. The three signals need different Stream Load settings, and a shared exporter forces one setting on all three. In the field this silently broke 100% of metric loads, whose retries then starved traces until Doris fell hours behind live. Declare doris/traces, doris/logs, and doris/metrics separately. The next section explains why.
Here is the minimal correct wiring. Everything Orbtrace-specific is in bold-comment lines:
exporters:
doris/traces:
endpoint: http://orbtrace-host:8030 # Doris FE — HTTP / Stream Load port
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { traces: otel_traces }
create_schema: false # REQUIRED — Orbtrace owns the schema
doris/logs:
endpoint: http://orbtrace-host:8030
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { logs: otel_logs }
create_schema: false
doris/metrics:
endpoint: http://orbtrace-host:8030
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { metrics: otel_metrics } # prefix — Doris stores five per-type tables
create_schema: false
service:
pipelines:
traces: { exporters: [your_existing_exporter, doris/traces] }
logs: { exporters: [your_existing_exporter, doris/logs] }
metrics: { exporters: [your_existing_exporter, doris/metrics] }This is the minimum. The production settings that make it fast and durable — batching, send queues, retries, Stream Load headers — are in The recommended production config below, ready to copy. Or just start from compose/otelcol-config.yaml, which already has them.
Pattern B customers also copy the processors
Orbtrace's reference config runs a handful of processors — cardinality protection, instance-id backfill, a log-timestamp fix — that keep bad data out of Doris. They are not automatic. If you manage your own Collector, copy them too. See Processors you shouldn't drop.
The Doris exporter, field by field
Every doris/* block shares the same connection fields. Here is what each one is for.
| Field | What it is | Notes |
|---|---|---|
endpoint | Doris FE HTTP address, port 8030 | Where Stream Load batches are POSTed. |
database | The Orbtrace database name | orbtrace. |
username / password | Doris login | A fresh bundled Doris runs root with an empty password. Set a password in production. |
table | Maps the signal to its table | otel_traces / otel_logs / otel_metrics. |
create_schema | Whether the exporter creates tables | Always false for Orbtrace. |
`create_schema: false` is the one line you must not forget
The exporter defaults create_schema to true — leave it out and it tries to build its own tables, racing the Orbtrace migrator. So you set it to false explicitly. Once it's false, the exporter's whole schema side switches off: it never opens a MySQL connection, and its mysql_endpoint, replication_num, and history_days options do nothing. That's why they're absent above — retention, partitioning, and replication all live in Orbtrace's own schema, not the exporter. There is nothing here to set for TTL.
Why Orbtrace owns the schema
Orbtrace's tables are not the stock exporter tables. Its first migration creates the base tables with the exact DDL the exporter expects — same table model, same distribution, same column types — then layers Orbtrace's own columns on top: stitched-trace edges, anomaly scores, sampling decisions. That is what powers span stitching, RCA, and replay. If the exporter created the schema instead, you would get the plain tables and lose those features. If both tried, you would get two half-built schemas. Hence create_schema: false, everywhere, always.
One exporter per signal: traces, logs, metrics
The three signals are stored differently in Doris, so they load differently. This is the reason for three exporters.
| Traces | Logs | Metrics | |
|---|---|---|---|
| Table | otel_traces | otel_logs | otel_metrics_* (five) |
| Table model | DUPLICATE KEY | DUPLICATE KEY | DUPLICATE KEY |
| Distribution | RANDOM | RANDOM | RANDOM |
load_to_single_tablet | "true" | "true" | "false" |
| Relative volume | High | High | Lower |
| Queue depth (prod) | Deepest | Medium | Shallow |
This layout is not Orbtrace-invented — it is exactly what Apache Doris recommends for observability data in its own observability docs: an append-only DUPLICATE KEY model, RANDOM distribution, time_series compaction, VARIANT columns for attribute bags, and inverted indexes on the queried fields. Orbtrace ships that schema and adds its own columns on top.
Traces
The heaviest signal and the one with the most processing. Traces flow through tail sampling (keep every error and slow trace, sample the rest), then land in otel_traces.
otel_traces is DISTRIBUTED BY RANDOM, which makes load_to_single_tablet: "true" valid and worthwhile — each batch goes to one tablet instead of fanning out across all of them, which cuts write amplification and compaction pressure and raises effective write concurrency. This is the single biggest Stream Load throughput lever for traces and logs.
Logs
Same storage shape as traces — RANDOM, single-tablet load on. Logs get two extra processors you should keep:
- A timestamp fix. Some SDKs emit logs with a zero timestamp. Doris has no partition for
1970-01-01, so Stream Load rejects the entire batch, the retry queue fills, and healthy services' logs start failing too. The fix backfills the receive-time timestamp. Pattern B customers must copy it, or one buggy SDK anywhere in the fleet takes down log ingest. - A severity filter. Below-INFO log noise is dropped at the Collector before it ever reaches Doris. Tune the floor with
ORBTRACE_LOG_DROP_BELOW(an OTTL severity constant, defaultSEVERITY_NUMBER_INFO); an escape-hatch attribute keeps individual records past it.
Metrics
table: { metrics: otel_metrics } is a prefix: OpenTelemetry has five metric shapes (gauge, sum, histogram, exponential histogram, summary), and Doris stores each in its own table — otel_metrics_gauge, otel_metrics_sum, and so on. You still declare one doris/metrics exporter; it routes each datapoint to the right table.
Metrics are the one signal where cardinality hurts. A single user_id label carrying a fresh value per request creates one new time series per user, and the metric tables grow without bound. Two processors defend against this — one deletes known bad label names, one folds UUID-shaped values into a single bucket. They run on metrics only; traces and logs keep their full values, because there the detail is the whole point.
Metrics use load_to_single_tablet: "false". Doris's general advice is "true" for any RANDOM table, but on Nivorbit's benchmark the flip gained nothing for the metric tables and slightly worsened the tail, so it stays off. Re-measure on your own hardware before changing it.
The recommended production config
The minimal Pattern B block connects, but it sends tiny, unbuffered loads. Below is the full recommended setup — all three signals, each with its own exporter and its own pipeline. That per-signal split is the whole reason for three exporters; logs and metrics are not optional add-ons, they each need a block. The three exporters are near-identical: only the values in the per-signal table below differ.
You don't have to type this — it ships ready-made
This is exactly what's in compose/otelcol-config.yaml, which also has the receivers and the full processor chain. Start from that file and adjust; the block below is here so you can see the knobs, not so you retype them.
extensions:
file_storage: # one shared on-disk queue; survives a Doris outage/restart
directory: /var/lib/otelcol/queue
timeout: 1s
compaction: # bbolt files never shrink without this
directory: /var/lib/otelcol/queue
on_start: true
on_rebound: true
rebound_needed_threshold_mib: 100
rebound_trigger_threshold_mib: 10
processors:
batch:
send_batch_size: 100000 # aim for large loads, not frequent tiny ones
send_batch_max_size: 200000
timeout: 5s
# memory_limiter, cardinality guards, log-timestamp fix and tail_sampling are
# defined in the reference config — see "Processors you shouldn't drop" below.
exporters:
# ── TRACES — heaviest signal → most consumers, deepest queue ──
doris/traces:
endpoint: http://orbtrace-host:8030
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { traces: otel_traces }
create_schema: false # required — Orbtrace owns the schema
timeout: 300s
log_response: true # the only place dropped-row counts show up
headers: { load_to_single_tablet: "true", max_filter_ratio: "0.01" }
sending_queue: { enabled: true, storage: file_storage, num_consumers: 20, queue_size: 1000 }
retry_on_failure: { enabled: true, initial_interval: 5s, max_interval: 30s, max_elapsed_time: 30s, randomization_factor: 0.5 }
# ── LOGS — same block; only num_consumers / queue_size change ──
doris/logs:
endpoint: http://orbtrace-host:8030
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { logs: otel_logs }
create_schema: false
timeout: 300s
log_response: true
headers: { load_to_single_tablet: "true", max_filter_ratio: "0.01" }
sending_queue: { enabled: true, storage: file_storage, num_consumers: 10, queue_size: 500 }
retry_on_failure: { enabled: true, initial_interval: 5s, max_interval: 30s, max_elapsed_time: 30s, randomization_factor: 0.5 }
# ── METRICS — single-tablet OFF (measured no-gain); smaller queue ──
doris/metrics:
endpoint: http://orbtrace-host:8030
database: orbtrace
username: ${env:DORIS_USER}
password: ${env:DORIS_PASSWORD}
table: { metrics: otel_metrics }
create_schema: false
timeout: 300s
log_response: true
headers: { load_to_single_tablet: "false", max_filter_ratio: "0.01" }
sending_queue: { enabled: true, storage: file_storage, num_consumers: 5, queue_size: 200 }
retry_on_failure: { enabled: true, initial_interval: 5s, max_interval: 30s, max_elapsed_time: 30s, randomization_factor: 0.5 }
service:
extensions: [file_storage]
pipelines:
# Each signal is its OWN pipeline feeding its OWN exporter — that is the point.
# Processor chains below are the recommended set (defined in the reference config).
traces: { receivers: [otlp], processors: [memory_limiter, transform/instance_id, attributes/cardinality_denylist, transform/cardinality_safety, tail_sampling, batch], exporters: [doris/traces] }
logs: { receivers: [otlp], processors: [memory_limiter, transform/instance_id, attributes/cardinality_denylist, transform/cardinality_safety, transform/log_timestamp_fix, filter/logs, batch], exporters: [doris/logs] }
metrics: { receivers: [otlp], processors: [memory_limiter, transform/instance_id, attributes/cardinality_denylist, transform/cardinality_safety, batch], exporters: [doris/metrics] }The only three values that change per signal:
| Setting | doris/traces | doris/logs | doris/metrics | Why |
|---|---|---|---|---|
num_consumers | 20 | 10 | 5 | Scale with each signal's volume — traces are heaviest. |
queue_size | 1000 | 500 | 200 | Deeper queue for the heavier signals. |
load_to_single_tablet | "true" | "true" | "false" | On for RANDOM traces/logs; measured no-gain for metrics. |
What each setting does, and when to change it
| Setting | Recommended | When to change it |
|---|---|---|
send_batch_size / _max_size | 100000 / 200000 | Lower for faster first-visible data on a quiet system; raise for more throughput — but respect the BE limit below. Dev uses 1024 for instant feedback. |
batch.timeout | 5s | Lower (e.g. 1s) if you want data visible sooner at low volume. |
timeout (Stream Load) | 300s | Rarely. Lines up with Doris's default load timeout; leave it. |
max_filter_ratio | "0.01" | Set "0" to reject any batch with a bad row (strictest); raise only if a known-noisy source needs slack. |
num_consumers | 20 / 10 / 5 | Raise with ingest rate, but keep the total across all exporters under ~128 per BE — Doris's per-BE load ceiling. Past that, consumers add contention, not throughput. |
queue_size | 1000 / 500 / 200 | Raise to buffer longer Doris outages — and grow the volume to match (see below). |
max_elapsed_time | 30s | Leave it. Longer just ties up a consumer on data that won't load. |
log_response | true | Leave it on. It is the only visibility into silently filtered rows. |
Batch size has a hard ceiling on the Doris side
The exporter sends each batch as one JSON body and the Doris BE buffers the whole thing before parsing. The binding limit is the BE's streaming_load_json_max_mb — default only 100 MB. A 200 000-event VARIANT-heavy batch can reach ~400 MB. Either raise streaming_load_json_max_mb to ≥ 512 on your BEs (the Orbtrace Helm chart does this), or cap send_batch_max_size at ≤ 50 000 on stock BEs. Undersize it and Stream Load rejects the batch.
Size the queue volume, or it silently drops
Worst-case disk ≈ Σ(queue_size × send_batch_max_size) × ~2 KB/event × 2 (bbolt no-shrink safety). For these production values that is roughly 6 GB — provision ≥ 8 Gi. An undersized volume silently drops the batches it can't grow to hold.
Delivery is at-least-once by design
Stream Load is not idempotent across retries — each attempt gets a fresh label, so a retried batch can land twice. That is an accepted trade for never dropping data; the short retry window bounds how often you hit the duplicate path.
Advanced tuning (opt-in)
Reach for these only when a signal tells you to. Defaults are correct for most deployments.
- Doris Group Commit. Server-side batching: Doris merges many concurrent Stream Loads into one internal commit (group commit manual), producing fewer tablet versions and far less compaction pressure. It is Doris's recommended answer for exactly the log/high-concurrency-small-load shape observability produces — a published benchmark shows ~16× throughput on 1 MB loads. Add
group_commit: "async_mode"to a signal'sheaders(defaults: flush every 10 s or 64 MB, tunable per table). Two caveats: (1) Stream Load + Group Commit has reported intermittent lost rows on Doris 4.0.2–4.1.0 (apache/doris#63160) — validate exact row-count parity on your build first. (2)async_modewrites to a WAL and silently falls back to normal load if WAL disk runs low, so watch WAL size. Turn it on only if tablet-version counts climb week-over-week and the single-tablet and queue levers are already in place. load_to_single_tabletfor metrics. Off by default (measured no-gain). If your hardware differs from ours, A/B it before flipping.- Batch size vs BE limit. Raise
send_batch_sizefor more throughput, but keep it under thestreaming_load_json_max_mbceiling described above. - Queue depth and consumers. Deeper
queue_sizebuffers longer Doris outages; morenum_consumersraises write parallelism. Scale both with ingest rate, and size the volume to match. - Network egress. The Doris exporter sends Stream Load bodies uncompressed. On a high-volume pipeline crossing a network boundary, size the link for raw event bytes, and keep the Collector close to Doris.
Deployment architectures
Patterns A and B answer what connects to Doris. This answers where the Collector layer lives. These are the OpenTelemetry community's standard layouts — agent and gateway — applied to Orbtrace.
Single host or VMs
One Collector next to the Orbtrace stack is the whole architecture. Every service — on that host or on other VMs — points OTEL_EXPORTER_OTLP_ENDPOINT at it. A single contrib Collector sustains tens of thousands of events per second before it needs help.
Your services
- Apps on this host and other VMs
- Each SDK sends OTLP to the Collector
One Collector
- Receives OTLP on :4317 / :4318
- tail_sampling + batching
- Durable on-disk send queue
Apache Doris
- FE on :8030
- Stores logs, traces, metrics
One box does everything. Scale signal: sustained Collector CPU saturation or a growing queue — then move to the two-tier layout below, not a bigger single Collector.
Kubernetes — node agents plus a gateway
The industry-standard Kubernetes layout is two tiers, and it is what we recommend.
Agent tierDaemonSet — one per node
- Receives OTLP from local pods
- k8sattributes enriches pod / ns / node
- Forwards to the gateway
Gateway tierDeployment — 2+ replicas
- tail_sampling (all spans of a trace)
- doris/* exporters + durable queue
- Holds the Doris credentials
Apache Doris
- FE on :8030
- Stores logs, traces, metrics
Pods send to their local node agent; agents forward to a small central gateway pool that owns sampling and the write to Doris.
- Agent tier — a DaemonSet. One Collector per node; pods send to their local node agent. The agent adds pod, namespace, deployment and node labels with the
k8sattributesprocessor — exactly the metadata you filter by in Orbtrace. Local delivery keeps SDK latency and cross-node traffic low. - Gateway tier — a small Deployment. Two or more replicas do the expensive, stateful work: tail sampling and the Doris exporters with their durable queues. Keeping Doris credentials in one tier also keeps your security review small.
- The one non-obvious rule. Tail sampling must see all spans of a trace on the same replica. With more than one gateway, the agents must export with
loadbalancingkeyed onrouting_key: traceID. Plain round-robin or client-IP stickiness splits a trace across replicas and breaks every multi-service sampling decision. - Zero-code instrumentation. Run the OpenTelemetry Operator and annotate workloads with its
InstrumentationCRD to inject auto-instrumentation without touching your images. It can also manage the Collector tiers themselves.
Start small: one gateway Deployment is enough. Add the DaemonSet when you want per-node metadata; split tail sampling to scaled gateways only when one replica saturates.
OpenShift
The same two tiers, with two platform notes:
- Operator: use the Red Hat build of OpenTelemetry from OperatorHub — it is the same OpenTelemetry Operator, packaged for OpenShift.
- Security: the contrib image runs as non-root, so the tiers run under the default
restricted-v2SCC with no grants. Give the gateway's queue volume write access withsecurityContext.fsGroup: 10001.
Orbtrace's own OpenShift install is on the OpenShift page; this section is only the pipeline.
Service mesh (Istio, Linkerd)
A mesh does not replace SDK instrumentation — the two answer different questions.
- Keep instrumenting your apps. Envoy sidecars only see traffic between pods. They cannot break a request into your handler, DB call and queue publish, and they cannot carry trace context through your code. An uninstrumented app in a mesh still produces disconnected one-hop spans.
- Agree on W3C
traceparent. The mesh and your SDKs must share one context-propagation format. W3C Trace Context is the standard and every OTel SDK's default; prefer it over legacy B3. - Mesh spans are an optional supplement. Istio's Telemetry API can export Envoy spans over OTLP — point it at your agent/gateway tier, at a low sampling percentage or not at all to start. With instrumented apps, Envoy spans mostly duplicate the picture while multiplying volume.
- One egress. App SDKs, mesh telemetry, and node agents all funnel through the same Collector layer to Doris. There is never a second path into Orbtrace to secure or debug.
Cost-aware sampling
Tail sampling keeps every error trace, every slow trace, every SLO-violating and anomaly-tagged trace, and samples the rest. The probabilistic floor for "the rest" is driven from Orbtrace: operators set a per-service monthly trace cap in /admin/sampling, and Orbtrace publishes the resolved policy as YAML at /api/sampling/policy.yaml. This defends Doris disk and keeps signal-to-noise high without you editing Collector config per service.
Static policy (default). The reference config ships fixed policies — keep errors, keep slow traces, keep force-tagged traces, sample everything else. Good for a single host or a first deploy.
Dynamic policy (production). The gateway pulls the live per-service policy and hot-reloads when it changes:
processors:
tail_sampling:
decision_wait: 30s # must exceed the longest trace you promise to keep
num_traces: 100000 # ≥ trace rate × decision_wait
decision_cache:
sampled_cache_size: 500000
non_sampled_cache_size: 1000000
policies: ${httppoll://orbtrace:8080/api/sampling/policy.yaml?interval=5m&auth_env=ORBTRACE_INGEST_TOKEN}Dynamic policy needs the httppoll provider
Stock otelcol-contrib fetches a config URL once at startup and never again — there is no refresh and no auth on the built-in http provider. Live policy needs either the httppoll confmap provider (in the otelcol-orbtrace distribution, or added to any OCB-built Collector via github.com/nivorbit/otelcol-confmap-httppoll), or a fetch-to-file-plus-SIGHUP sidecar. auth_env names an env var whose value is sent as a bearer token; the URL never carries the secret.
Honest limit of the hard-keep promise
Tail sampling decides decision_wait seconds after a trace's first span arrives, using only the spans that have arrived by then. A trace that runs longer than decision_wait is decided on partial data, so its slow root may be missed. Set decision_wait above the longest trace you promise to keep, size num_traces ≥ rate × decision_wait, and keep decision_cache on. Error spans are safer than slow spans — an error is visible immediately; "slow" only exists once the span ends.
Processors you shouldn't drop
Pattern A users get these for free from the reference config. Pattern B users managing their own Collector must copy them — they keep bad data out of Doris:
memory_limiter— first in every pipeline. Refuses new data when the heap runs hot, so the Collector sheds load instead of crashing.transform/instance_id— backfillsservice.instance.idfromk8s.pod.nameorhost.name, so Orbtrace's per-instance breakdown resolves instead of collapsing to "(unknown)".attributes/cardinality_denylist+transform/cardinality_safety— delete per-user label names and fold UUID-shaped values, so metrics don't explode into unbounded time series.transform/log_timestamp_fix— backfills zero timestamps from buggy SDKs, so one bad SDK can't reject whole log batches.
What Orbtrace does not touch
Orbtrace reads from Doris. It never reconfigures your apps or your Collector for you — you own the pipeline. Tuning beyond this reference config (custom processors, multi-gateway fan-out) is a separate engagement, not a prerequisite.