Logging and Observability for ML Systems

Metrics show that something changed; logs and traces show which requests, which step and which model version. This guide logs every prediction of a real intent classifier as JSON, traces each request with OpenTelemetry and joins the true labels later, finding that 9% of requests held 41% of the errors.

GloryTecks Team

Training & Placement Faculty

6 May 2026 8 min read

Last updated: 8 October 2026

Share:

When a model service misbehaves, metrics tell you that something changed. Observability is being able to ask the follow-up questions without shipping new code: which requests, which model version, which step, and were the answers actually right?

For machine learning that comes down to three habits. Log every prediction in a structured form. Trace each request through its steps. Keep an ID that lets the true outcome be joined when it arrives. This guide does all three on a real intent classifier.

Three signals, one request

OpenTelemetry describes telemetry as signals: traces, metrics, logs and baggage (OpenTelemetry: signals).

SignalQuestion it answersIn this demo
MetricsHow many, how fast, how often?Not shown here; see the Prometheus guide
LogsWhat happened to this request?One JSON line per prediction
TracesWhich steps ran, and in what order?predict, vectorize, classify, handoff

A trace is a tree of spans that share one trace ID. The root span is the one with no parent, and it covers the whole operation (OpenTelemetry: traces). The OpenTelemetry Python implementation lists traces and metrics as stable and logs as still in development (OpenTelemetry: Python). So this demo writes logs with Python's standard logging and adds the trace ID to every line itself.

What a prediction log should hold

  • A request ID and the trace ID, to join outcomes later and to jump from a log line to its trace.
  • The model version, to compare versions.
  • The prediction, its confidence and the decision taken.
  • A fingerprint of the input, such as a hash and length, rather than raw text.
  • Latency.

Each line should be a parseable record, not a sentence. Python's logging cookbook shows how to emit structured messages that programs can read (Python: logging cookbook).

Logging the inputs is valuable. Google's Rules of ML say the best way to train like you serve is to save the features used at serving time and pipe them to a log for training (Google: Rules of ML). It is also risky. OWASP lists session identifiers, access tokens and personal data among the things to keep out of logs, and suggests de-identification such as pseudonymisation where identity is not needed (OWASP: logging cheat sheet). Keep feature logs in a separate, access-controlled store with a retention limit.

The experiment

Banking77 holds online banking queries labelled with 77 intents: 10,003 for training and 3,080 for testing, under CC BY 4.0 (PolyAI: Banking77). A TF-IDF and logistic regression classifier is trained on the first set, and the 3,080 test queries are replayed as live traffic. Below 0.30 confidence, the conversation goes to a person.

Each request writes one JSON log line and one trace through the OpenTelemetry SDK, kept in memory here. Afterwards the true intents arrive and are joined on the request ID.

python
import hashlib
import json
import logging
import random
import time

import pandas as pd
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline

train, live = pd.read_csv("train.csv"), pd.read_csv("test.csv")   # Banking77; the test split stands in for traffic
model = make_pipeline(TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True),
                      LogisticRegression(max_iter=3000, C=10)).fit(train.text, train.category)
VERSION, HANDOFF = "intents-tfidf-v1", 0.30                       # below 0.30 confidence, a person answers

random.seed(7)               # the SDK draws IDs from Python's random module; seeded only to make this page reproducible
spans = InMemorySpanExporter()                                    # in production: an OTLP exporter to a tracing backend
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(spans))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("intent-service")


class JsonLines(logging.Formatter):
    def format(self, record):
        return json.dumps(record.fields)


handler = logging.FileHandler("predictions.jsonl", mode="w")
handler.setFormatter(JsonLines())
log = logging.getLogger("predictions")
log.addHandler(handler)
log.setLevel(logging.INFO)


def predict(request_id, text):
    with tracer.start_as_current_span("predict", attributes={"request.id": request_id, "model.version": VERSION}) as root:
        start = time.perf_counter()
        with tracer.start_as_current_span("vectorize"):
            features = model[0].transform([text.strip()])
        with tracer.start_as_current_span("classify") as span:
            probabilities = model[1].predict_proba(features)[0]
            intent, confidence = model.classes_[probabilities.argmax()], round(float(probabilities.max()), 3)
            span.set_attributes({"prediction.intent": intent, "prediction.confidence": confidence})
        if confidence < HANDOFF:
            with tracer.start_as_current_span("handoff_to_agent"):
                pass                                              # a real service would queue the conversation here
        log.info("prediction", extra={"fields": {
            "event": "prediction", "request_id": request_id, "trace_id": format(root.get_span_context().trace_id, "032x"),
            "model_version": VERSION, "text_sha256": hashlib.sha256(text.encode()).hexdigest()[:16], "chars": len(text),
            "intent": intent, "confidence": confidence, "handoff": confidence < HANDOFF,
            "latency_ms": round((time.perf_counter() - start) * 1000, 2)}})


for number, text in enumerate(live.text):
    predict(f"req-{number:05d}", text)
handler.close()

records = [json.loads(line) for line in open("predictions.jsonl", encoding="utf-8")]
logs = pd.DataFrame(records)
finished = spans.get_finished_spans()
print(f"{len(live):,} requests -> {len(logs):,} log lines, {len({s.context.trace_id for s in finished}):,} traces, "
      f"{len(finished):,} spans")
print("spans by name:", {name: int(n) for name, n in pd.Series([s.name for s in finished]).value_counts().items()})

record = next(r for r in records if r["handoff"])
print("\none handed-off request, as logged (latency omitted because it varies):")
print(json.dumps({k: v for k, v in record.items() if k != "latency_ms"}))
print("its trace:")
same_trace = [s for s in finished if format(s.context.trace_id, "032x") == record["trace_id"]]
for span in sorted(same_trace, key=lambda s: s.parent is not None):   # root first, then children in the order they ended
    print(f"  {'  ' if span.parent else ''}{span.name:<18} {dict(span.attributes)}")

truth = pd.DataFrame({"request_id": [f"req-{n:05d}" for n in range(len(live))], "true_intent": live.category})
joined = logs.merge(truth, on="request_id")                       # labels arrive later; join them on the request ID
joined["correct"] = joined.intent == joined.true_intent
band = pd.cut(joined.confidence, [0, 0.3, 0.5, 0.7, 1.001], right=False,
              labels=["below 0.3", "0.3 to 0.5", "0.5 to 0.7", "0.7 and above"])
table = joined.groupby(band, observed=True).correct.agg(requests="size", accuracy="mean", errors=lambda c: (~c).sum())
table["share of errors"] = table.errors / table.errors.sum()
print(f"\naccuracy once labels arrive: {joined.correct.mean():.1%}")
print(table.to_string(formatters={"accuracy": "{:.0%}".format, "share of errors": "{:.0%}".format}))
handed_off = joined[joined.handoff].true_intent.value_counts().head(3)
print("\nintents most often handed off:", {intent: int(n) for intent, n in handed_off.items()})
text
3,080 requests -> 3,080 log lines, 3,080 traces, 9,518 spans
spans by name: {'vectorize': 3080, 'classify': 3080, 'predict': 3080, 'handoff_to_agent': 278}

one handed-off request, as logged (latency omitted because it varies):
{"event": "prediction", "request_id": "req-00000", "trace_id": "6513270e269e0d37f2a74de452e6b438", "model_version": "intents-tfidf-v1", "text_sha256": "57a5a4533e3e3cf2", "chars": 24, "intent": "get_physical_card", "confidence": 0.106, "handoff": true}
its trace:
  predict            {'request.id': 'req-00000', 'model.version': 'intents-tfidf-v1'}
    vectorize          {}
    classify           {'prediction.intent': 'get_physical_card', 'prediction.confidence': 0.106}
    handoff_to_agent   {}

accuracy once labels arrive: 89.4%
               requests accuracy  errors share of errors
confidence                                              
below 0.3           278      52%     134             41%
0.3 to 0.5          361      73%      99             30%
0.5 to 0.7          453      87%      58             18%
0.7 and above      1988      98%      36             11%

intents most often handed off: {'compromised_card': 9, 'card_acceptance': 8, 'topping_up_by_card': 8}

What the output shows

  • Logs and traces agree. 3,080 requests produced 3,080 log lines and 3,080 traces, and the 278 handoff spans match the 278 handoffs in the logs.
  • The log line leads straight to the trace. The first request was handed off. Its log holds no text, only a hash prefix and a length of 24 characters, plus the trace ID, and the trace shows the root span with its three steps. In the public dataset, that query reads "How do I locate my card?" The model guessed get_physical_card at 0.106 confidence; the true intent was card_arrival, so the handoff prevented a wrong answer.
  • The joined labels found where the errors live. Overall accuracy was 89.4%. The requests below 0.3 confidence were 9% of traffic but 134 of the 327 errors, 41%. Above 0.7, accuracy was 98%.
  • The threshold is now a decision backed by evidence. Moving the handoff line to 0.5 would send 639 requests, 21% of traffic, to people and catch 233 errors, 71% of them.
  • The logs point at the next data to collect. The intents handed off most were compromised_card, card_acceptance and topping_up_by_card.

Running this in production

  • Export spans to a tracing backend with an OTLP exporter instead of keeping them in memory.
  • Never seed the random module for trace IDs; the demo does it only to make the page reproducible.
  • Sample routine traffic, but keep every handoff, error and a random slice, so volume stays affordable.
  • Set retention by purpose, shorter for anything near personal data.
  • Put the same request ID in every log line and span, across every service a request touches.

For metrics and alerting on the same service, see Prometheus and Grafana for ML monitoring. For the overall monitoring plan, see monitoring ML models in production. For a language-model take on the same support task, see building a customer support bot with LLMs.

Common mistakes

  • Free-text log messages that no program can query.
  • No request ID, so outcomes can never be joined.
  • Raw inputs with personal data in general-purpose logs.
  • No model version on predictions.
  • Logs and traces that cannot be linked, because the trace ID is missing from the log.

What should an ML model log for each prediction?

A request ID, the trace ID, the model version, the prediction with its confidence and any decision taken, a fingerprint of the input such as a hash and length, and the latency, as one structured record per request.

What is the difference between monitoring and observability?

Monitoring watches known signals and alerts when they cross thresholds. Observability is the ability to investigate new questions after the fact, using detailed logs, traces and metrics that can be sliced by request, version or step.

Should I log the model's input features?

Usually yes, because serving-time features are the most reliable training data and the best evidence when debugging. Keep them out of general logs if they contain personal data, and store them with access control and a retention limit.

Do I need OpenTelemetry for an ML service?

Not to start, but it gives traces a standard format and lets one request be followed across services. Its Python tracing and metrics are stable, while its logs signal is still in development, so many teams pair it with standard logging.

Logging and exception handling appear in the Python for MLOps module of the MLOps course, and logging with alerts in its Model Monitoring and Maintenance module.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master MLOps?

Join GloryTecks’ expert-led MLOps training in Hyderabad with real-time projects and 100% placement support.

Explore course