← Back to list

From Logs to Analytics: Designing ADX for Long-Term Observability

The customer operated multiple Kubernetes clusters generating high-volume application logs. They already had a mature log shipping layer…

Michael Liav · 2026-02-11 07:54 · 1 claps · 7.8 min read
#microsoft-azure #adx #observability
Open on Medium ↗
Wiki topics: GRW · Growth & Analytics ☁️ · DevOps & Cloud

From Logs to Analytics: Designing ADX for Long-Term Observability

The customer operated multiple Kubernetes clusters generating high-volume application logs. They already had a mature log shipping layer using Vector, which gave them strong control over parsing, enrichment, filtering, and routing.

Their requirements were clear:

  • Sustain very high ingestion throughput
  • Support deep investigative analytics
  • Remain cost-efficient at scale
  • Allow future growth without re-architecture

Initially, observability looked simple.

A managed logging platform. Clean dashboards. Alerts configured. Everything under control.

Then scale happened.

Log volume grew from a few gigabytes per day to hundreds. Retention requirements expanded beyond 30 days. The questions changed.

Instead of “Is the service down?” engineers began asking:

  • Why did latency increase across three clusters last Tuesday?
  • What is the error distribution by deployment version?
  • Can we reconstruct a transaction across services six months back?
  • How does memory pressure correlate with rollout windows?

At that point, traditional logging platforms started to feel restrictive. Sometimes financially. Sometimes analytically.

That is where Azure Data Explorer entered the conversation.

Architecture Is the Real Observability Decision

Real systems are messy.

Schemas drift. Applications evolve. Logs break. Teams grow. Security wants its own pipeline. Compliance asks for reprocessing six months later.

We evaluated several ingestion patterns.

Option 1: Direct HTTP Ingestion

Architecture:

Application → Vector → ADX HTTP ingestion endpoint → Table

Vector uses its HTTP sink to push logs directly into ADX using the ingestion REST API. Example from local file:

# Data Directory
data_dir = "./vector-data"

# Global Configuration
[api]
enabled = true
address = "127.0.0.1:8686"

# Source - Read all events from both apps
[sources.all_apps]
type = "file"
include = ["logs/events.log"]
read_from = "end"

# Transform - Parse JSON and add metadata
[transforms.parse_events]
type = "remap"
inputs = ["all_apps"]
source = '''
  # Parse the JSON log entry
  . = parse_json!(string!(.message))

  # Add host information if not present
  if !exists(.host) {
    .host = get_hostname!()
  }

  # Add source identifier
  if !exists(.source) {
    .source = "vector-demo"
  }

  # Ensure app_type exists (critical for routing)
  if !exists(.app_type) {
    .app_type = "unknown"
  }
'''

# Route - Split events by app_type
[transforms.route_by_app]
type = "route"
inputs = ["parse_events"]

route.app_a = '.app_type == "app_a"'
route.app_b = '.app_type == "app_b"'

# Sink - App A (Logs) to ADX via HTTP
[sinks.adx_app_a]
type = "http"
inputs = ["route_by_app.app_a"]
uri = "${ADX_APP_A_ENDPOINT}"
encoding.codec = "json"

# Authentication
auth.strategy = "bearer"
auth.token = "${ADX_BEARER_TOKEN}"

# Headers
[sinks.adx_app_a.request.headers]
Content-Type = "application/json"
Accept = "application/json"

# Batch settings (ADX can handle larger batches)
[sinks.adx_app_a.batch]
timeout_secs = 5
max_bytes = 1048576  # 1MB

# Sink - App B (Metrics) to ADX via HTTP
[sinks.adx_app_b]
type = "http"
inputs = ["route_by_app.app_b"]
uri = "${ADX_APP_B_ENDPOINT}"
encoding.codec = "json"

# Authentication
auth.strategy = "bearer"
auth.token = "${ADX_BEARER_TOKEN}"

# Headers
[sinks.adx_app_b.request.headers]
Content-Type = "application/json"
Accept = "application/json"

# Batch settings
[sinks.adx_app_b.batch]
timeout_secs = 5
max_bytes = 1048576  # 1MB

# Console sink for debugging
[sinks.console]
type = "console"
inputs = ["parse_events"]
encoding.codec = "json"

Pros

  • Minimal architecture
  • Lower infrastructure cost
  • Fewer components to manage
  • Fast implementation
  • Straightforward ownership

Cons

  • No external durable buffer
  • If ADX throttles, retries happen at the shipper level
  • No independent replay capability
  • Harder to introduce additional consumers
  • Tighter coupling between producers and ADX schema
  • Routing complexity if many apps dynamically map to tables

ADX does provide internal ingestion queuing, but there is no user-controlled streaming layer in this model.

At small scale, this works well.

At high scale with long retention and evolving schemas, it becomes fragile.

Option 2: Single Event Hub with Multiple Consumer Groups

Architecture:

Applications → Vector → Event Hub → Multiple Consumers

ADX reads with one consumer group. Security tooling or ML pipelines use others.

Example from local file:

# Vector Configuration - Multi-App Routing
# Routes multiple apps to same Event Hub with app_type field and different consumer groups
# ADX update policies handle the routing to different tables

data_dir = "./vector-data"

[api]
enabled = true
address = "127.0.0.1:8686"

# Source - Read all events (both apps write to same file)
[sources.all_apps]
type = "file"
include = ["logs/events.log"]
read_from = "end"

# Transform - Parse and ensure app_type field exists
[transforms.parse_events]
type = "remap"
inputs = ["all_apps"]
source = '''
. = parse_json!(string!(.message))

# Add host information if not present
if !exists(.host) {
  .host = get_hostname!()
}

# Add source identifier
.source = "vector-demo"

# Ensure app_type exists (critical for ADX routing)
if !exists(.app_type) {
  .app_type = "unknown"
}
'''

# Route - Split events by app_type
[transforms.route_by_app]
type = "route"
inputs = ["parse_events"]
route.app_a = '.app_type == "app_a"'
route.app_b = '.app_type == "app_b"'

# Transform - Add Table field for ADX routing (App A)
[transforms.add_table_app_a]
type = "remap"
inputs = ["route_by_app.app_a"]
source = '''
.Table = "AppA_Data"
'''

# Transform - Add Table field for ADX routing (App B)
[transforms.add_table_app_b]
type = "remap"
inputs = ["route_by_app.app_b"]
source = '''
.Table = "AppB_Data"
'''

# Sink - App A to Event Hub/Kafka
[sinks.kafka_app_a]
type = "kafka"
inputs = ["add_table_app_a"]
bootstrap_servers = "${EVENTHUB_NAMESPACE}.servicebus.windows.net:9093"
topic = "${EVENTHUB_NAME}"
compression = "none"
group_id = "app_a"

[sinks.kafka_app_a.encoding]
codec = "json"

[sinks.kafka_app_a.sasl]
enabled = true
mechanism = "PLAIN"
username = "$$ConnectionString"
password = "${EVENTHUB_CONNECTION_STRING}"

[sinks.kafka_app_a.tls]
enabled = true

[sinks.kafka_app_a.librdkafka_options]
"api.version.request" = "false"
"broker.version.fallback" = "1.0.0"
"request.required.acks" = "1"

[sinks.kafka_app_a.batch]
timeout_secs = 1
max_bytes = 1048576

# Sink - App B to Event Hub/Kafka
[sinks.kafka_app_b]
type = "kafka"
inputs = ["add_table_app_b"]
bootstrap_servers = "${EVENTHUB_NAMESPACE}.servicebus.windows.net:9093"
topic = "${EVENTHUB_NAME}"
compression = "none"
group_id = "app_b"

[sinks.kafka_app_b.encoding]
codec = "json"

[sinks.kafka_app_b.sasl]
enabled = true
mechanism = "PLAIN"
username = "$$ConnectionString"
password = "${EVENTHUB_CONNECTION_STRING}"

[sinks.kafka_app_b.tls]
enabled = true

[sinks.kafka_app_b.librdkafka_options]
"api.version.request" = "false"
"broker.version.fallback" = "1.0.0"
"request.required.acks" = "1"

[sinks.kafka_app_b.batch]
timeout_secs = 1
max_bytes = 1048576

# Console sinks for debugging
[sinks.console_app_a]
type = "console"
inputs = ["add_table_app_a"]

[sinks.console_app_a.encoding]
codec = "json"

[sinks.console_app_b]
type = "console"
inputs = ["add_table_app_b"]

[sinks.console_app_b.encoding]
codec = "json"

Pros

  • Independent readers
  • Replay flexibility per consumer
  • Clean extensibility model
  • Supports multi-pipeline architectures

Cons

  • Still shared ingestion bottleneck
  • Partition scaling must satisfy all workloads
  • Throughput tuning impacts everyone
  • Monitoring and capacity planning become more complex

This improves extensibility but does not address producer isolation.

Option 3: Event Hub per Application or Domain

Architecture:

Applications → Vector → Dedicated Event Hub → ADX Table

Example from local file:

# Vector Configuration - Multi-App Routing
# Routes multiple apps to different EventHubs
# ADX update policies handle the routing to different tables

data_dir = "./vector-data"

[api]
enabled = true
address = "127.0.0.1:8686"

# -------------------
# Sources
# -------------------

[sources.all_apps]
type = "file"
include = ["logs/events.log"]
read_from = "end"

# -------------------
# Transforms
# -------------------

[transforms.parse_events]
type = "remap"
inputs = ["all_apps"]
source = '''
. = parse_json!(string!(.message))

# Add host information if not present
if !exists(.host) {
  .host = get_hostname!()
}

# Add source identifier
.source = "vector-demo"

# Ensure app_type exists (critical for ADX routing)
if !exists(.app_type) {
  .app_type = "unknown"
}
'''

[transforms.route_by_app]
type = "route"
inputs = ["parse_events"]
route.app_a = '.app_type == "app_a"'
route.app_b = '.app_type == "app_b"'

[transforms.add_table_app_a]
type = "remap"
inputs = ["route_by_app.app_a"]
source = '''
.Table = "AppA_Data"
'''

[transforms.add_table_app_b]
type = "remap"
inputs = ["route_by_app.app_b"]
source = '''
.Table = "AppB_Data"
'''

# -------------------
# Sinks - App A
# -------------------

[sinks.kafka_app_a]
type = "kafka"
inputs = ["add_table_app_a"]
bootstrap_servers = "${EVENTHUB_NAMESPACE_A}.servicebus.windows.net:9093"
topic = "${EVENTHUB_NAME_A}"
compression = "none"

[sinks.kafka_app_a.encoding]
codec = "json"

[sinks.kafka_app_a.sasl]
enabled = true
mechanism = "PLAIN"
username = "$$ConnectionString"
password = "${EVENTHUB_CONNECTION_STRING_A}"

[sinks.kafka_app_a.tls]
enabled = true

[sinks.kafka_app_a.librdkafka_options]
"api.version.request" = "false"
"broker.version.fallback" = "1.0.0"
"request.required.acks" = "1"

[sinks.kafka_app_a.batch]
timeout_secs = 1
max_bytes = 1048576

# -------------------
# Sinks - App B
# -------------------

[sinks.kafka_app_b]
type = "kafka"
inputs = ["add_table_app_b"]
bootstrap_servers = "${EVENTHUB_NAMESPACE_B}.servicebus.windows.net:9093"
topic = "${EVENTHUB_NAME_B}"
compression = "none"

[sinks.kafka_app_b.encoding]
codec = "json"

[sinks.kafka_app_b.sasl]
enabled = true
mechanism = "PLAIN"
username = "$$ConnectionString"
password = "${EVENTHUB_CONNECTION_STRING_B}"

[sinks.kafka_app_b.tls]
enabled = true

[sinks.kafka_app_b.librdkafka_options]
"api.version.request" = "false"
"broker.version.fallback" = "1.0.0"
"request.required.acks" = "1"

[sinks.kafka_app_b.batch]
timeout_secs = 1
max_bytes = 1048576

# -------------------
# Console Sinks
# -------------------

[sinks.console_app_a]
type = "console"
inputs = ["add_table_app_a"]

[sinks.console_app_a.encoding]
codec = "json"

[sinks.console_app_b]
type = "console"
inputs = ["add_table_app_b"]

[sinks.console_app_b.encoding]
codec = "json"

This is the architecture we chose.

Pros

  • Fault isolation between applications
  • Independent partition scaling
  • Clear ownership boundaries
  • Reduced noisy neighbor risk
  • Cleaner monitoring per workload
  • Clear cost attribution

Cons

  • Higher infrastructure cost
  • More Event Hubs to manage
  • Increased operational complexity
  • Requires governance to prevent sprawl

In high-scale systems, isolation is often more valuable than consolidation.

This model introduced a durable streaming backbone while preserving clean separation between producers and consumers.

Option 4: Raw Staging Tables with Update Policies

We also evaluated introducing a raw staging pattern inside ADX.

Architecture:

Applications → Vector → Event Hub → ADX Raw Table → Update Policies → Curated Tables

Example from local file:

# Vector Configuration
# Applications → Vector → Event Hub
# --------------------------------------------

data_dir = "./vector-data"

[api]
enabled = true
address = "127.0.0.1:8686"

# Source - Application logs
[sources.app_logs]
type = "file"
include = ["logs/events.log"]
read_from = "end"

# Transform - Minimal normalization (raw preservation)
[transforms.normalize]
type = "remap"
inputs = ["app_logs"]
source = """
# Parse JSON payload
. = parse_json!(string!(.message))

# Preserve full raw payload for ADX Raw Table
.raw_payload = .

# Add ingestion metadata
.ingest_time = now()
.source = "vector"
"""

# Sink - Event Hub (Kafka endpoint)
[sinks.eventhub_raw]
type = "kafka"
inputs = ["normalize"]
bootstrap_servers = "${EVENTHUB_NAMESPACE}.servicebus.windows.net:9093"
topic = "${EVENTHUB_NAME}"
compression = "none"

[sinks.eventhub_raw.encoding]
codec = "json"

[sinks.eventhub_raw.sasl]
enabled = true
mechanism = "PLAIN"
username = "$$ConnectionString"
password = "${EVENTHUB_CONNECTION_STRING}"

[sinks.eventhub_raw.tls]
enabled = true

[sinks.eventhub_raw.librdkafka_options]
"api.version.request" = "false"
"broker.version.fallback" = "1.0.0"
"request.required.acks" = "1"

[sinks.eventhub_raw.batch]
timeout_secs = 1
max_bytes = 1048576

In this pattern, the raw table stores the full dynamic payload. Update policies project structured columns into curated tables used for analytics.

Pros

  • Raw payload preservation
  • Safe schema evolution
  • Reprocessing capability
  • Clear separation between ingestion and analytics
  • Reduced ingestion-time transformation risk

Cons

  • Higher storage footprint
  • More complex data model
  • Additional operational overhead
  • Requires strong lifecycle governance

This pattern is powerful, especially in environments with frequent schema drift or strong data engineering requirements.

However it becomes complex when each application has different log schema.

The Final Architecture

Applications → Vector → Event Hub (per application or domain) → Corresponding ADX Table

This provided:

  • Durable buffering
  • Replay capability
  • Independent scaling characteristics
  • Clear isolation boundaries
  • Long-term analytical power in ADX

Yes, this architecture costs more than direct HTTP ingestion.

But architecture should be evaluated in terms of risk and evolution.

The streaming backbone allowed:

  • Absorbing traffic spikes
  • Adding new consumers without redesign
  • Scaling workloads independently
  • Maintaining clean failure boundaries
  • Preserving long-term retention with analytical depth

The HTTP sink model was cheaper and simpler. It also shifted risk upstream and limited extensibility.

For small systems, that trade-off may be acceptable.

For high-scale observability platforms with long retention and investigative workloads, it becomes a liability.

Final Reflection

Observability is not about dashboards. It is not even about ADX.

It is about designing an ingestion path that can evolve without collapsing under growth.

In this case, introducing Event Hub per application was not about adding complexity.

It was about reducing systemic risk.

In distributed systems, reducing risk is often the most cost-effective decision you can make.


메타데이터
post_id
12c172b07ef4
slug
from-logs-to-analytics-designing-adx-for-long-term-observability-12c172b07ef4
url
https://medium.com/@immichaelliav/from-logs-to-analytics-designing-adx-for-long-term-observability-12c172b07ef4
canonical_url
https://medium.com/@immichaelliav/from-logs-to-analytics-designing-adx-for-long-term-observability-12c172b07ef4
author_url
https://medium.com/@immichaelliav
status
ok
fetched_at
2026-06-22 00:13:37