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
Last updated: 8 October 2026
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).
| Signal | Question it answers | In this demo |
|---|---|---|
| Metrics | How many, how fast, how often? | Not shown here; see the Prometheus guide |
| Logs | What happened to this request? | One JSON line per prediction |
| Traces | Which 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.
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()})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?
What is the difference between monitoring and observability?
Should I log the model's input features?
Do I need OpenTelemetry for an ML service?
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
- Signals (OpenTelemetry documentation) — OpenTelemetry
- Traces (OpenTelemetry documentation) — OpenTelemetry
- Python (OpenTelemetry documentation) — OpenTelemetry
- Rules of Machine Learning (Google for Developers) — Google
- Logging Cookbook (Python documentation) — Python Software Foundation
- Logging Cheat Sheet (OWASP) — OWASP
- Task-specific datasets, including Banking77 (GitHub, PolyAI) — PolyAI
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.
Related articles
Production ML Systems: Architecture and Best Practices
In a production machine learning system the model is the smallest part. This guide maps the components around it, compares the three serving patterns, explains the ML-specific technical debt that architecture has to contain, and sets out the practices that keep a system maintainable as it grows.
MLOps Roadmap 2026: Skills, Tools and Career Path
MLOps sits between data science, software engineering and operations, which makes it hard to know where to start. This roadmap orders the skills from foundations to production operations, gives each stage a project that proves the skill, and explains what the MLOps engineer role actually involves.
MLflow Tutorial: Tracking Experiments and Models
MLflow records what produced each model so you never have to remember. This tutorial trains three models on real bike-sharing data, logs every run, compares them by querying the tracking store, registers the best one, points a champion alias at it and loads it back by that alias, all on a laptop.