De vorige post verbond Claude Code met de MCP-server van het Aspire dashboard. Logs, traces, resource state — allemaal te bevragen vanuit de editor.
Dat werkt alleen als het draaiende systeem ook iets nuttigs te zeggen heeft.
De meeste .NET-apps zenden veel tijdens runtime. HTTP-spans, EF Core-spans, HttpClient-spans, een muur aan ILogger-output. Niets daarvan beantwoordt de vragen die je daadwerkelijk hebt op een slechte middag: welke tenant was dit? hoe groot was de batch? hebben we gededupliceerd voor we wegschreven? waar precies ging dit fout?
De informatie ontbreekt omdat niemand hem erin heeft gestopt.
Deze post is het derde deel van de kleine Aspire-reeks. Het gaat over het produceren van telemetrie die het bevragen waard is — met Claude Code als het instrument dat de juiste ActivitySource toevoegt, verdedigbare attributen kiest en de sampling-configuratie reviewt voordat hij in productie belandt.
Vereisten
Je hebt nodig:
- een .NET 8 (of nieuwer) applicatie — Aspire-hosted is handig maar niet vereist
- de OpenTelemetry .NET SDK (Aspire wired die op via
ServiceDefaults) - een OTLP-compatibele exporter — het Aspire dashboard lokaal, Application Insights / Seq / Jaeger / Tempo in productie
- Claude Code
Alles hieronder werkt zonder Aspire. De reden dat ik bij het Aspire-voorbeeld blijf is continuïteit met de twee vorige posts, en omdat het dashboard het resultaat zichtbaar maakt zonder extra plumbing.
Wat je gratis krijgt
Aspire’s ServiceDefaults-project zet OpenTelemetry standaard aan. De AddOpenTelemetry-call ziet er meestal zo uit:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation();
});
builder.Services.Configure<OpenTelemetryLoggerOptions>(o =>
{
o.IncludeFormattedMessage = true;
o.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.UseOtlpExporter();
Voor Grouply — het multi-tenant fleet-platform uit de eerdere posts — levert dat me out of the box:
- een span per inkomende HTTP-request naar
apiservice - een span per uitgaande HttpClient-call
- een span per EF Core-query
- runtime metrics (GC, threads, GC pauses)
- log records die het actieve trace- en span-id meedragen
Voor een POST /imports-request laat het dashboard dan de HTTP-span zien als parent, met EF Core-spans voor elke query en HttpClient-spans voor elke verrijkings-call.
Nuttig, maar plat.
De feitelijke workflow — batch valideren, VINs dedupliceren, verrijken vanuit de externe registratie-API, persisteren, publiceren naar de Service Bus, audit — wordt platgeslagen tot één inkomende HTTP-span. De vorm van je domein is onzichtbaar. In het dashboard kun je niet zien welke tenant de import deed, hoe groot de batch was, hoeveel voertuigen werden afgewezen, of dat het trage stuk dedupliceren of verrijken was.
Dat is het gat dat custom instrumentatie dicht.
Custom spans voor business-operaties
De Grouply-case: VehicleImportService.ImportBatchAsync(tenantId, batch). Valideert de batch, dedupliceert op VIN, verrijkt vanuit een externe registratie-API, schrijft naar Postgres, en publiceert één event per geaccepteerd voertuig naar de vehicles-topic op de Service Bus.
Zonder instrumentatie is die workflow één span, hoe complex hij ook wordt.
De minimale verandering is één ActivitySource per service en één StartActivity per operatie die een naam waard is:
public sealed class VehicleImportService
{
private static readonly ActivitySource ActivitySource =
new("Grouply.VehicleImport");
private readonly IVehicleRepository _repository;
private readonly IRegistrationLookup _lookup;
private readonly IVehicleEventPublisher _publisher;
public async Task<ImportResult> ImportBatchAsync(
Guid tenantId,
IReadOnlyList<VehicleDto> batch,
CancellationToken ct)
{
using var activity = ActivitySource.StartActivity("ImportBatch");
activity?.SetTag("tenant.id", tenantId);
activity?.SetTag("import.batch_size", batch.Count);
var deduplicated = await DeduplicateAsync(batch, ct);
var enriched = await EnrichAsync(deduplicated, ct);
var persisted = await PersistAsync(tenantId, enriched, ct);
await PublishAsync(persisted, ct);
activity?.SetTag("import.accepted_count", persisted.Count);
activity?.SetTag("import.rejected_count",
batch.Count - persisted.Count);
return new ImportResult(persisted.Count, batch.Count - persisted.Count);
}
}
Voor elke binnenstap (DeduplicateAsync, EnrichAsync, PersistAsync, PublishAsync) doe je hetzelfde: open een child-activity, zet de handvol tags die voor die stap relevant zijn, laat hem sluiten op dispose.
Registreer de source zodat de SDK hem ook daadwerkelijk exporteert:
tracing.AddSource("Grouply.VehicleImport");
Het dashboard laat nu een spans-tree zien zoals deze:
POST /imports [apiservice]
└── ImportBatch tenant.id=…, batch_size=120
├── Deduplicate duplicates=4
├── Enrich external_calls=116
├── Persist accepted=114
└── Publish events=114
Zelfde workflow, zelfde code paths — leesbaar in plaats van ondoorzichtig.
Ik schrijf dit zelden meer vanaf nul. Ik vraag Claude Code:
Voeg OpenTelemetry-spans toe aan
VehicleImportService.ImportBatchAsyncen de private helpers. Gebruik één statischeActivitySourcemet de naamGrouply.VehicleImport. Zet tags voor tenant id, batch size en per-stap tellingen. Markeer de activity als gefaald wanneer een exception ontsnapt. Volg de OpenTelemetry semantic conventions voor naamgeving.
De redenen om Claude Code dit te laten doen in plaats van het zelf uit te tikken:
- het onthoudt het
Activity?null-conditional patroon voor wanneer sampling de span dropt - het krijgt de package-referentie goed (
System.Diagnostics.DiagnosticSource) - het produceert consistente tag-namen tussen services in dezelfde solution
- het pikt bestaande semantic-convention-namen op waar die er zijn, in plaats van nieuwe te verzinnen
Wat het niet voor je kan beslissen: welke operaties een span verdienen. Dat is een domeinbeslissing. Vuistregel: instrumenteer alles met een eigen retry, timeout, fout-categorie of SLA. Een loop-iteratie verdient bijna nooit een eigen span. Een cross-service call bijna altijd wel.
Attributen die geen ruis zijn
Tags zijn hoe spans doorzoekbaar worden. Het is ook hoe telemetrie-rekeningen ontploffen en hoe PII in observability-storage lekt.
Drie dimensies om te wegen voor je een tag toevoegt:
Cardinaliteit. De kosten van een tag groeien met het aantal verschillende waarden dat hij kan aannemen. tenant.id heeft honderden waarden over de hele klantbase — prima. vehicle.vin heeft miljoenen — elke span wordt uniek, aggregatie breekt, de backend klaagt. Stop high-cardinality identifiers in span events of structured logs, niet in span tags.
Kosten. De meeste managed exporters factureren per attribute volume. Application Insights, Datadog, New Relic — ze rekenen allemaal voor wat je verstuurt. Een tag die je op elke span in een 50-RPS service zet, zijn miljoenen writes per dag.
PII. Alles wat je op een span zet wordt geëxporteerd, geïndexeerd en bewaard zo lang de backend telemetrie vasthoudt. Emails, namen, volledige adressen, ruwe request bodies — niets daarvan hoort er thuis.
Voor de Grouply import-flow ziet de splitsing er ruwweg zo uit:
- ✅
tenant.id— beperkte cardinaliteit, nuttig voor filteren, op zichzelf geen PII - ✅
import.batch_size,import.accepted_count,import.rejected_count— kleine integer-ranges, nuttige aggregaties - ✅
import.source(api,csv,partner_feed) — eencijferige cardinaliteit, erg nuttig - ✅
retry.count— bounded, drijft alerting - ❌
vehicle.vin— miljoenen unieke waarden, kapot voor aggregatie - ❌
user.email— PII, nooit als span tag - ❌
request.body— PII-risico plus exporter-kosten - ❌
connection_string, alles dat eindigt opsecret— voor de hand liggend, maar het zeggen waard
Dit is precies het soort review waar Claude Code geschikt voor is. Nadat het spans heeft toegevoegd, vraag ik vaak:
Review de span-attributen die je net hebt toegevoegd op drie criteria: cardinaliteit (geen per-record identifiers), kosten (geen grote strings), en PII (geen emails, namen, adressen, ruwe payloads). Markeer elke tag die op één van deze faalt en stel een veiliger alternatief voor.
Het zal soms terugkomen op zijn eigen eerdere keuzes — wat precies is wat je wilt.
Traces correleren met logs
De OpenTelemetry logging bridge verbindt ILogger-output aan de actieve span. Met WithLogging geconfigureerd (Aspire’s ServiceDefaults doet dit al), draagt elke logregel automatisch het huidige trace-id en span-id mee:
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeFormattedMessage = true;
o.IncludeScopes = true;
o.ParseStateValues = true;
});
Die ene hook is wat de MCP-queries uit de vorige post bruikbaar maakt. Zonder hem geeft de vraag aan Claude Code
Laat errors zien van
apiservicein de afgelopen 10 minuten.
een lijst aan strings terug. Mét hem heeft elke regel in die lijst een trace-id, en vervolgvragen als
Haal de volledige trace op voor de meest recente gefaalde import, inclusief de logregels die aan elke span hangen.
leveren een spans-tree op met de structured logs op de juiste knooppunten.
Hier klikt de trilogie dicht. Post 1 maakte de AppHost leesbaar. Post 2 maakte de runtime bevraagbaar. Deze post zet de signal op de juiste plek zodat de queries uit post 2 ook daadwerkelijk iets nuttigs teruggeven.
Een kleine gewoonte loont hier: log aan het begin en einde van elke geïnstrumenteerde operatie, met de structured fields die je ook op de span zou zetten. De logregel blijft menselijk leesbaar, de span draagt dezelfde fields als tags, en trace-correlatie doet de rest.
using var activity = ActivitySource.StartActivity("ImportBatch");
activity?.SetTag("tenant.id", tenantId);
activity?.SetTag("import.batch_size", batch.Count);
_logger.LogInformation(
"Starting import for tenant {TenantId} with {BatchSize} vehicles",
tenantId, batch.Count);
De redundantie is bewust. De span is voor de trace-timeline, de log is voor het tekstuele verhaal. Ze worden gekoppeld via het trace-id.
Sampling — wanneer 100% je rekening sloopt
Lokaal wordt elke span geëxporteerd. Het Aspire dashboard handelt het zonder klagen af, en je wilt volle fidelity tijdens debuggen.
In productie overleeft dat het contact met de factuurpagina van je backend niet.
Een bescheiden service met 50 requests per seconde, met vijf custom spans per request, produceert ongeveer 22 miljoen spans per dag. Tegen Application Insights-tarieven is dat een merkbaar bedrag op de rekening. Vermenigvuldig met het aantal services en het aantal omgevingen, en het beeld wordt snel duidelijker.
Twee strategieën, beide ondersteund door OpenTelemetry:
Head-based sampling beslist aan het begin van een trace of die bewaard wordt. Goedkoop, voorspelbaar, eenvoudig te configureren. Het nadeel is dat je de keep/drop-beslissing maakt voordat je weet of de trace errors bevatte.
tracing.SetSampler(new ParentBasedSampler(
new TraceIdRatioBasedSampler(0.1)));
Dat houdt 10% van de traces, alles-of-niets per trace, en respecteert de sampling-beslissing van de parent als die er is. Goede default. Slechte fit als je error-rate onder de sampling-rate ligt, want zeldzame errors worden gedropt voordat je ze ooit ziet.
Tail-based sampling beslist nadat de trace klaar is. Errors en trage traces worden bewaard, gezonde snelle traces worden weggesampled. Het draait in de OpenTelemetry Collector (of een vendor-equivalent) in plaats van in-process.
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 2000 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 5 }
Errors altijd bewaard. Trage traces altijd bewaard. 5% van de rest als baseline. De collector buffert traces in geheugen totdat hij kan beslissen, en dat is waarom decision_wait ertoe doet.
Een strategie kiezen is een afweging. Service-vorm, error-rate, budget en hoe agressief je latency-uitschieters wilt onderzoeken — alles speelt mee. Claude Code is hier nuttig als reviewer, niet als beslisser:
Review mijn sampling-configuratie. De service handelt ongeveer 50 requests per seconde af met een error-rate van 0,5%. Ik wil alle errors behouden en elke trace die langzamer is dan 2 seconden, plus een kleine baseline. Vertel me wat deze configuratie wel en niet zal vangen, en wat ik moet monitoren na het uitrollen.
Behandel het antwoord als startpunt. Echte traffic-vorm verrast je altijd.
Waar dit tekortschiet
OpenTelemetry beweegt snel, en de snel bewegende delen kunnen pijn doen.
Semantic conventions verschuiven. HTTP-attribuutnamen zijn de afgelopen releases veranderd — http.method werd http.request.method, http.status_code werd http.response.status_code. Dashboards gebouwd op de oude namen stopten stilletjes met matchen. Als je je vastpint op één versie van de conventie, documenteer dat.
SDK churn. OpenTelemetry’s .NET-pakketten releasen vaak. Minor-versies mengen tussen OpenTelemetry, OpenTelemetry.Extensions.Hosting en de verschillende OpenTelemetry.Instrumentation.*-pakketten is een recept voor runtime-verrassingen. Houd ze op één lijn en update ze samen.
Auto-instrumentatie kan te enthousiast zijn. EF Core-instrumentatie zendt een span uit voor elke query, inclusief de praterige queries van health checks en identity middleware. HttpClient-instrumentatie zendt een span uit voor elke retry binnen een Polly-policy. Het juiste antwoord is meestal een filter — tracing.AddEntityFrameworkCoreInstrumentation(o => o.SetDbStatementForText = false), of een custom processor die ruisspans dropt — niet de instrumentatie weghalen.
Kosten zijn echt. Application Insights factureert per GB ingestion. Een naïeve 100% sampling-configuratie op een drukke service kan een vier-cijferig maandbudget verbranden zonder dat iemand het merkt totdat de factuur binnenkomt.
Claude Code kan je traffic-vorm niet voorspellen. Het geeft je een redelijke default sampling-configuratie, maar het kent je echte RPS, error-rate, latency-distributie of fairness-regels tussen tenants niet. De eerste week na het uitrollen van custom instrumentatie heeft een mens nodig die naar dashboards kijkt.
Attribuut-keuze blijft een afweging. Cardinaliteits-bommen en overduidelijke PII zijn makkelijk te markeren. Of vehicle.year op elke span hoort, of tenant.tier op een span of alleen op een metric — dat zijn domein-beslissingen. Vraag, delegeer niet.
Wat dit afmaakt
Het Aspire-boog kent drie delen.
De eerste post maakte de architectuur leesbaar. Claude Code leest de AppHost, volgt de dependency graph en legt uit wat aan wat is verbonden.
De tweede post maakte de runtime bevraagbaar. De MCP-server van het Aspire dashboard laat Claude Code het draaiende systeem vragen wat er gebeurt — logs, traces, resource state, restart commands.
Deze post zet de signal in je code. Custom spans voor business-operaties, attributen die nuttig en veilig zijn, sampling die productie overleeft, en logs gecorreleerd aan traces. Zonder deze laag levert post 2 lijsten met HTTP-spans en muren van ongestructureerde logregels op. Mét hem landt elke vraag uit post 2 op telemetrie die iets te zeggen heeft.
Wil je een concrete volgende stap, kies dan één service. Voeg een ActivitySource toe. Instrumenteer één workflow met Claude Code:
Voeg OpenTelemetry-spans toe aan deze service voor de import-workflow. Gebruik één statische ActivitySource. Zet tags voor tenant id, batch size en per-stap tellingen. Review daarna de attributen op cardinaliteit, kosten en PII-risico.
Start vervolgens de AppHost, draai de workflow, en ga terug naar de MCP-server uit de vorige post:
Laat me de meest recente trace zien voor een import op tenant
acme, inclusief de structured logs die aan elke span hangen.
De eerste keer dat het antwoord terugkomt als een schone spans-tree met de juiste tags en de juiste logregels op de juiste spans, sluit de loop.
De blueprint zit in de AppHost.
Het gedrag zit in het dashboard.
De signal zit in je code — en nu is hij het lezen waard.