Skip to content

Configuration

Config is layered, lowest precedence first: built-in defaults < an optional YAML file (--config path.yaml) < G2O_* environment variables. A key you omit from the YAML file keeps its default; a supported environment override always wins. See config.example.yaml in the repo for the fully-commented authoritative source this page mirrors.

No config file is required at all — with no --config flag, graph2otel runs from built-in defaults plus whatever G2O_* environment variables are set, which is the container-friendly path (see Getting Started).

Strict validation

Configuration mistakes stop the process. Normal startup and graph2otel check run the same validation before constructing credentials, checkpoint stores, telemetry exporters, collectors, or network clients:

  • Unknown keys in fixed YAML objects fail with the complete path, including sequence indexes and collector map keys, such as tenants[0].collectors["entra.directory_audits"].soruce.
  • Unknown G2O_* variables fail by their exact environment-variable name, such as G2O_OTLP__GRAFANA_CLOUD__TOKNE. Values are not included in these diagnostics.
  • Collector override names must match a name in the generated collector reference. An unambiguous near miss also gets a did you mean "..." suggestion.
  • source must be unset or exactly graph or blob, and is accepted only for a source-switchable collector. Setting it on any other collector is an error, including an explicit source: graph.

Collector-name, interval, and source faults from YAML use paths such as collectors["entra.directory_audits"].source or tenants[0].collectors["entra.directory_audits"].source. The same faults supplied by environment report the exact G2O_COLLECTORS__... variable instead.

The dynamic keys are deliberate and limited: collector-name maps remain open long enough to validate names against the runtime registry, while profiling.pyroscope.tags accepts arbitrary string keys. Their containing objects and collector override values remain strict.

Environment variable mapping

Fixed scalar keys are settable via environment variables named with the G2O_ prefix and __ (double underscore) as the nesting delimiter. A single underscore inside a field name (e.g. log_level) is preserved as-is — only level boundaries use __:

YAML keyEnvironment variable
log_levelG2O_LOG_LEVEL
otlp.protocolG2O_OTLP__PROTOCOL
otlp.endpointG2O_OTLP__ENDPOINT
otlp.grafana_cloud.instance_idG2O_OTLP__GRAFANA_CLOUD__INSTANCE_ID
otlp.grafana_cloud.tokenG2O_OTLP__GRAFANA_CLOUD__TOKEN
admin.enabledG2O_ADMIN__ENABLED
admin.addrG2O_ADMIN__ADDR
admin.refresh_intervalG2O_ADMIN__REFRESH_INTERVAL
cost.enabledG2O_COST__ENABLED
cost.periodG2O_COST__PERIOD
cost.rates.log_record_microunitsG2O_COST__RATES__LOG_RECORD_MICROUNITS
cost.budget_microunitsG2O_COST__BUDGET_MICROUNITS
checkpoint_dirG2O_CHECKPOINT_DIR
backfill.initial_lookbackG2O_BACKFILL__INITIAL_LOOKBACK
collectors["entra.signins.interactive"].enabledG2O_COLLECTORS__ENTRA.SIGNINS.INTERACTIVE__ENABLED
collectors["entra.signins.interactive"].intervalG2O_COLLECTORS__ENTRA.SIGNINS.INTERACTIVE__INTERVAL
collectors["entra.directory_audits"].sourceG2O_COLLECTORS__ENTRA.DIRECTORY_AUDITS__SOURCE

Global collector overrides are the only dynamic environment form: G2O_COLLECTORS__<NAME>__(ENABLED|INTERVAL|SOURCE). <NAME> is the exact collector name uppercased, with dots and single underscores preserved. No other collector leaf is accepted.

Structured tenants and free-form profiling.pyroscope.tags cannot be expressed by a flat environment variable. Multi-tenant setups therefore need YAML for tenants:, and profile tags must also stay in the file.

Top-level keys

log_level

debug | info | warn | error. Default info.

tenants

A list of Entra tenants to poll. At least one entry is required unless otlp.protocol is stdout. Each entry:

tenants:
  - tenant_id: "00000000-0000-0000-0000-000000000000" # hyphenated Entra directory GUID
    client_id: "" # optional expected app ID; an assertion, not credential selection
    exclude_self: false
    collectors: # optional per-tenant overrides — see "Per-collector overrides" below
      "entra.signins.interactive":
        enabled: false

tenant_id is required to be the hyphenated Entra directory GUID. Hex digits are case-insensitive, but verified domains, arbitrary names, compact UUIDs and braced UUIDs are rejected. graph2otel sets that GUID on every token request, permits the credential to target only that directory, and verifies the returned token's tid before any collector or tenant-labelled signal starts.

azidentity.DefaultAzureCredential independently selects one ambient application identity for the process. A configured client_id is only an optional, non-secret consistency assertion about that identity; it is never passed to the credential chain and cannot select or override it. If it differs from the appid proved from the actual Graph access token, startup warns once and the authenticated ID wins.

Client-secret and certificate material comes from the process environment. Workload identity uses the platform-injected federated-token environment; managed identity uses the host-assigned identity. AZURE_CLIENT_ID selects the single ambient workload or user-assigned managed identity where required. Managed identity stays bound to its home tenant and ignores a cross-tenant request; a returned tid mismatch fails that tenant closed before data is emitted. None of that auth material belongs in this file. See Getting Started.

When exclude_self: true, graph2otel compares record appId only with the authenticated token's proved appid. If token acquisition or claim decoding cannot prove a non-empty ID, the filter fails open: graph2otel retains every record and emits one bounded startup warning for that tenant. A configured client_id alone is never proof.

Per-tenant ingest and direct-API sources

The shipped registry has 170 logical collectors across 7 registration paths. These tenant blocks enable or configure the non-default source paths; they are file-only because the environment layer does not bind into the tenants[] slice:

tenants:
  - tenant_id: "00000000-0000-0000-0000-000000000000"
    blob_ingest:
      account_url: "https://myaccount.blob.core.windows.net"
      metric_recency_window: 20m
    o365_activity:
      content_types: ["Audit.Exchange", "Audit.SharePoint"]
    mdca:
      portal_url: "https://<tenant>.<region>.portal.cloudappsecurity.com"
      token_file: "/run/secrets/mdca_token"
    exchange_online:
      enabled: true
    hunting:
      enabled: true
  • blob_ingest.account_url is the opt-in for the read-only Azure Storage byte-offset consumer. The ambient identity needs Storage Blob Data Reader on the account; an Azure subscription Owner role does not grant blob-content reads. metric_recency_window defaults to 20m and must be at most 1h; older blob events still emit logs but not counters, so backfill is not credited to the current interval. Azure diagnostic delivery is at-least-once, so blob log duplicates are preserved and deduplicated downstream by record ID.
  • o365_activity.content_types configures the default-on, stable-v1.0 m365.activity collector. Empty means Audit.Exchange plus Audit.SharePoint. Audit.AzureActiveDirectory, Audit.General, and DLP.All are supported explicit additions; DLP.All requires ActivityFeed.ReadDlp. The API has no record-level server filter, so every record in a selected type is fetched and shipped. Audit.General is deliberately not a default: on the measured six-device tenant, 3,865 of 4,035 records over 23 hours were Endpoint DLP. The common audit mapper does not emit PolicyDetails, SensitiveInformation, or DetectedValues; there is no dedicated DLP.All classification mapper.
  • mdca.portal_url opts into Cloud Discovery parse health over the legacy MDCA portal API. token_file is required and contains the static portal token; the secret itself must not appear in YAML or an environment variable.
  • exchange_online.enabled opts into the Exchange Online admin collectors. It needs both Exchange.ManageAsApp and an Entra directory role on the service principal (Security Reader is the least-privileged verified role).
  • hunting.enabled opts into the Graph advanced-hunting query collectors. It needs ThreatHunting.Read.All and consumes a per-tenant CPU budget shared with interactive Defender portal queries.

These blocks do not map one-to-one to ingest engines. The 4 ingest engine shapes are Graph window polling, async export jobs, Azure diagnostic blobs, and the O365 Management Activity subscription/content-blob flow. Snapshot, MDCA, Exchange Online, and Hunt collectors retain their direct API shapes.

otlp

otlp:
  protocol: http # grpc | http | stdout
  endpoint: "https://otlp-gateway-prod-us-central-0.grafana.net/otlp"
  grafana_cloud:
    instance_id: ""
    token: ""
  • protocolgrpc, http, or stdout. stdout prints OTLP-shaped metrics and logs to the console instead of exporting over the network — the local-debugging path, and the only mode that's allowed to run with zero configured tenants.
  • endpoint — the OTLP receiver URL. Defaults to Grafana Cloud's US-central OTLP gateway; override for another region or backend.
  • grafana_cloud.instance_id / .token — Grafana Cloud OTLP auth. token is a credential and must be set via G2O_OTLP__GRAFANA_CLOUD__TOKEN, never written into YAML — it's documented here only to name the key.

collectors

Global per-collector overrides, keyed by collector name, applied across every tenant:

collectors:
  "entra.signins.interactive":
    enabled: true
    interval: "5m" # duration string: "30s", "5m", "168h" (minimum 1s)

A collector absent from this map runs enabled at its built-in default interval. enabled unset means "default true", which is distinct from an explicit false — the config layer tracks that difference so a lower layer's explicit disable isn't silently overridden by a higher layer's absence of an opinion. interval unset (or 0) means "use the collector's built-in default". Collector names must match the generated collector reference exactly.

source selects the ingest transport for the current source-switchable collectors: entra.directory_audits, entra.provisioning, and entra.risk_detections. It is optional; unset defaults to graph. The only accepted values are graph and blob. Applying either value to any other collector is rejected:

collectors:
  "entra.directory_audits":
    source: blob

Per-collector overrides (tenant beats global)

The same CollectorConfig shape (enabled / interval / source) appears at the top level (collectors:, applied to every tenant) and per-tenant (tenants[].collectors:). Resolution order, field-by-field:

per-tenant override > global collectors: > collector's built-in default

So one tenant can disable a collector — or retune its poll interval — that the rest of the fleet keeps at its default, without touching the global block.

Experimental / beta collectors are opt-in, not default-on

Some collectors depend on a Microsoft Graph beta endpoint with no v1.0 equivalent (see Signals and the per-collector reference for which ones). These never register on the implicit "unset means enabled" default — they require an explicit enabled: true at some config layer (global or per-tenant) before they run at all. Setting enabled: false (or leaving a collector unmentioned) both mean "not explicitly enabled" for this purpose; only an explicit true opts in. This is a deliberate stability gate: a beta Graph endpoint can change shape or disappear without the same compatibility guarantees as v1.0.

admin

admin:
  enabled: false
  addr: ":9090"

Exposes the operator health/status HTTP server. Disabled by default.

  • /healthz is process liveness and does not depend on collector outcomes.
  • /readyz returns 503 until the first successful collector run, then latches ready for the rest of the process lifetime.
  • Partial tenant success is ready while failed tenants/collectors remain degraded in status. If no collector has ever succeeded, readiness stays 503.
  • A zero-tenant stdout diagnostic run is ready immediately.
  • Failure to bind admin.addr is fatal; the process never runs silently without its configured health surface.

The status page also renders a per-tenant throttle-headroom panel — the live client-side rate-limiter state (limit/s, burst, tokens available, headroom %) for each Graph workload the tenant has actually hit since start-up — so you can see how close a tenant is running to Graph's throttling ceilings.

It also exposes exact cumulative collector volume and process-level OTLP transport totals. The optional cost view described below appears only when cost.enabled is true. It is labelled estimate, not invoice.

cost

cost:
  enabled: false
  currency: ""
  version: ""
  source: ""
  effective_at: ""
  period: 720h
  rates:
    source_record_microunits: null
    metric_point_microunits: null
    log_record_microunits: null
    transmitted_payload_byte_microunits: null
  budget_microunits: 0

Cost projection is observational and disabled by default. graph2otel does not ship a vendor price table or try to discover one. When enabled, the operator must provide the currency and the identity, provenance, effective timestamp, and rates for the schedule being modelled:

  • currency — an upper-case three-letter ASCII currency code.
  • version — a nonblank identifier for the supplied rate schedule.
  • source — a nonblank description or reference identifying where the rates came from.
  • effective_at — the schedule's RFC3339 effective timestamp.
  • period — a positive duration used to project an observed interval. The default is 720h (30 days).
  • rates.* — four explicit, nonnegative integer rates: per logical source record, per emitted metric point, per emitted log record, and per post-compression OTLP payload byte. An explicit 0 is valid; an omitted rate is not valid while cost projection is enabled. Helm values are capped at 9007199254740991 so its YAML/JSON rendering path preserves each integer exactly.
  • budget_microunits — a nonnegative comparison value for the projection period. 0 disables the comparison. The same Helm exact-integer cap applies.

A microunit is 10⁻⁶ of the configured currency unit. Integer rates and integer arithmetic keep the result exact at that scale without presenting floating-point rounding as a billing fact.

The logical source-record, metric-point, and log-record components are exact runtime counts. OTLP payload bytes are exact only for the process as a whole. graph2otel allocates metric payload bytes over metric points and log payload bytes over log points independently; one signal can never be charged to a collector which emitted only the other. That allocation is always labelled estimated. Signal bytes with no same-signal collector point share remain in an explicit _unattributed / process row rather than disappearing. The collector rows plus that row reconcile to the process interval estimate.

The interval estimate retains every traffic class. The configured-period projection and budget ratio include steady_state only: a finite cold-start backfill or replay is shown as interval cost but is never annualised as recurring traffic. The admin waits for at least two complete metric-export intervals and uses up to about ten minutes of observations to reduce exporter cadence mismatch, refreshes the projection no more than once per minute, and exposes the actual observed duration. A budget produces only a ratio in the admin status and UI. Neither pricing nor a crossed budget can sample, throttle, delay, disable, or drop a collector, and it cannot change the cardinality limiter or exporter. See Volume, transport, and estimated cost for the exact measurement boundaries.

profiling

profiling:
  pyroscope:
    enabled: false
    server_address: ""            # REQUIRED when enabled, e.g. https://profiles-prod-NNN.grafana.net
    basic_auth_user: ""           # Grafana Cloud Profiles user/instance ID
    basic_auth_password: ""       # supply via env, NEVER here — see below
    tenant_id: ""                 # optional; leave empty for Grafana Cloud
    upload_rate: 15s              # optional; 0/omit uses the pyroscope default
    tags: {}                      # optional static labels attached to every profile
  mutex_profile_fraction: 5       # runtime.SetMutexProfileFraction; 0 = disabled
  block_profile_rate: 100000      # runtime.SetBlockProfileRate (ns); 0 = disabled

Optional Grafana Pyroscope continuous profiling, off by default. graph2otel does not expose an HTTP pprof endpoint — it only pushes profiles to Pyroscope, so nothing is served or scrapeable from the process. Enabling it has no effect on the exporter's core job, and a failure to reach Pyroscope is non-fatal (logged, then the process carries on).

pyroscope.server_address is required when enabled is true. basic_auth_user / basic_auth_password authenticate to Grafana Cloud Profiles; on a self-hosted Pyroscope they can be left empty. upload_rate controls how often profiles are flushed (default 15s); tags attaches static labels to every profile.

mutex_profile_fraction and block_profile_rate turn on the Go runtime's mutex- and block-contention sampling that feed the corresponding Pyroscope profiles. Sampling them is not free, so leave the defaults unless you are actively investigating contention.

basic_auth_password is a secret — set it via G2O_PROFILING__PYROSCOPE__BASIC_AUTH_PASSWORD, never in committed YAML (see Secrets). Every scalar field here also has a G2O_PROFILING__* env var; tags is file-only. See Environment variables.

checkpoint_dir

checkpoint_dir: "./checkpoints"

Root directory for the file-based checkpoint store. Window collectors persist their per-(tenant, endpoint) watermark and seen-ID overlap set; async jobs persist their job and record cursor state; O365 Management Activity persists its arrival watermark plus content- and record-ID sets; blob ingest persists exact byte offsets. A restart therefore resumes each source's own cursor contract instead of inventing one universal timestamp cursor. A correctly timestamped record with no ID still emits as undedupeable/degraded, while a record with no parseable event time is dropped and counted rather than stamped with arrival time. See Architecture and Signals.

backfill

backfill:
  initial_lookback: 0s

How far back a window (log) collector reaches on a cold start — no checkpoint yet: a new tenant, a wiped volume, a first deploy. It bounds how much history that start recovers.

0 (the default) means use each collector's own built-in lookback, which is not one value: most streams use 1h, m365.unified_audit 4h, entra.security_incidents 24h — each tuned to its endpoint's data latency and throttling ceiling. A non-zero value replaces all of them, so set it for a deliberate recovery rather than as a permanent default.

It does not affect the steady state. Once a checkpoint exists, polling resumes from the watermark, and a gap longer than a collector's max window is walked forward in capped chunks across successive ticks — losslessly. This key only governs the case where there is no checkpoint to resume from.

Grafana Cloud has a measured seven-day ceiling. Its OTLP gateway strictly rejects entries older than 7 days. The rejection is explicit and per-entry: an HTTP 400 reaches the OTel error handler for each over-age entry, while in-window entries in the same batch remain accepted. [live-measured 2026-07-22, #226]

Accepted in-window backdated records can be indexed later than fresh records. An immediately empty query is therefore not evidence of rejection; the explicit gateway response is. See Backdated log records.

The value still loads and validates as written, but the poll is clamped to 165h (#401) — deliberately 3h inside the 7-day window, because a rejection observed in production on 2026-07-27 was only about an hour past the limit. Reaching further back is not a longer recovery; it is the same recovery plus per-entry rejections. A self-hosted Loki may be configured wider and a non-Loki OTLP sink has its own rules, so raise the emit horizon deliberately if yours accepts more.

grafana_annotations

The opt-in Grafana annotation writer — graph2otel's one non-OTLP egress path. Off unless url is set. Full reference: Grafana annotations.

grafana_annotations:
  url: "" # Grafana base URL; setting it IS the opt-in
  token: "" # service-account token — env or token_file only, never here
  token_file: "" # path to a mounted token file; value XOR file
  dashboard_uid: "" # empty = organization annotations, visible to every board
  timeout: 10s
  max_per_minute: 60 # hard ceiling on writes; overage is dropped and counted
  queue_size: 512
  rollup_interval: 5m
  dedupe_retention: 48h
  categories:
    config_posture: { enabled: true, rollup: true }
    security_incident: { enabled: true, rollup: false }
    service_health: { enabled: true, rollup: false }
    license: { enabled: true, rollup: true }

The token needs exactly one Grafana action — annotations:create on annotations:type:organization — and graph2otel uses no other Grafana permission. The built-in Annotations writer role (fixed:annotations:writer) grants that plus write and delete, which graph2otel never uses; the documented minimum is a custom role scoped to create alone. See The required Grafana permission for the measured detail and how to create it.

Once url is set, the process refuses to start if the token cannot write an annotation. That is deliberate: discovering it at the first real event means the annotations an operator relies on for incident context are absent exactly when they look for them.

The persisted dedupe key set lives in checkpoint_dir, so it needs the same persistent volume — without one, every restart republishes everything inside the source collectors' overlap windows.

Secrets — what never belongs in this file

  • Tenant credentials (client secret, certificate path, or workload/managed identity) are never read from tenants[] or any other key here. They come from the ambient environment or host identity used by azidentity.DefaultAzureCredential.
  • tenants[].tenant_id is the hyphenated directory GUID used to bind each token request and verify its returned tid. tenants[].client_id may assert the expected non-secret application ID, but it cannot choose the credential.
  • otlp.grafana_cloud.token is a credential and belongs in G2O_OTLP__GRAFANA_CLOUD__TOKEN, never in YAML.
  • grafana_annotations.token is a credential and belongs in G2O_GRAFANA_ANNOTATIONS__TOKEN or grafana_annotations.token_file, never in YAML.
  • config.local.yaml and .env are gitignored in this repo for exactly this reason — don't commit a filled-in config that contains anything beyond tenant/client IDs.

See Security for the full rationale.

The configuration fingerprint on the startup marker

Every process start emits a graph2otel.startup log record carrying a config.fingerprint — 16 hex characters derived from the effective configuration, so a dashboard can annotate "the configuration changed here" without publishing the configuration. Two consecutive markers with different fingerprints mean something in this file (or its environment overrides) changed between the restarts.

What matters for this file:

  • No configuration value is ever emitted, only the hash, and the hash cannot be reversed into the configuration.
  • Credentials never enter the hash input at all. Every credential key here is a redacting type, so otlp.grafana_cloud.token, profiling.pyroscope.basic_auth_password and grafana_annotations.token contribute the literal REDACTED. Tenant auth material is not on this surface in the first place.
  • Rotating a credential does not change the fingerprint. Setting one that was previously unset does — that is a behavior change, not a secret.
  • Every key participates, including keys added in later releases, so a cosmetic edit moves the fingerprint too. It over-reports rather than under-reports on purpose.
  • It is process-wide, not per-tenant. Editing one tenant's block moves the fingerprint on every tenant's marker.
  • There is no key to turn it off, by design — see Signals for the full field set and the reasoning.