diff --git a/AGENTS.md b/AGENTS.md index d72ff6c3..4064f6e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,14 +43,16 @@ Three pillars: artifact + a queryable surface) so a backend can pre-provision, a collector can validate against the declared surface, and CI can treat telemetry as a typed, versioned API. -**Status (current tree — do not overstate):** First-Light steps 1–2 are shipped — +**Status (current tree — do not overstate):** First-Light steps 1–3 are shipped — `TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly's public type `QylTelemetryCapabilityGraph` (its manifest body filled via a generator `partial`, gated to the core assembly like `SemConvRegistryGenerator`) — `.Json` / `.SchemaVersion` / `.CapabilityCount` (the queryable surface), with the vendor-neutral exchange schema in -`docs/schema/telemetry-capability-graph.schema.json` and `docs/TELEMETRY_CAPABILITY_GRAPH.md`. Next: -the OTLP resource-log publication channel at boot (mapping already specified in the exchange spec) — -note qyl carries no OTel SDK dependency, so that channel needs a BCL-native emission decision. +`docs/schema/telemetry-capability-graph.schema.json` and `docs/TELEMETRY_CAPABILITY_GRAPH.md`. The +`Qyl.OpenTelemetry.AutoInstrumentation.Publishing` package adds the runtime open-exchange channel: +`AddQylTelemetryCapabilityGraphPublisher()` emits the TCG as a true OTel `LogRecord` at host startup +through `ILogger` (the OTLP exporter stays app-owned; no OTel SDK dependency in qyl), proven by +`demos/Qyl.RealTcgPublishingDemo`. Next: the static build-artifact channel and a remote queryable endpoint. **What does NOT change:** runtime DiagnosticListeners stay. `docs/experiments/precompilation-verdict.md` measured that ~95% of attribute *values* are runtime-only — listeners are the **runtime lane** of @@ -154,6 +156,8 @@ Keep dependency-heavy integrations isolated: - Microsoft.Data.SqlClient code belongs in `Qyl.OpenTelemetry.AutoInstrumentation.SqlClient`. - Generic hosting/bootstrap code belongs in `Qyl.OpenTelemetry.AutoInstrumentation.Hosting`. - Core shared runtime helpers belong in `Qyl.OpenTelemetry.AutoInstrumentation`. +- TCG runtime publishing (the OTel-`LogRecord` exchange channel) belongs in + `Qyl.OpenTelemetry.AutoInstrumentation.Publishing` — opt-in, `ILogger`-based, no OTel SDK dependency. EFCore lives in `Qyl.OpenTelemetry.AutoInstrumentation.EntityFrameworkCore` and SqlClient in `Qyl.OpenTelemetry.AutoInstrumentation.SqlClient`; their dependencies, build warnings, and app-side NativeAOT diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e81ca48..d52cdd7c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,6 +31,7 @@ + diff --git a/Qyl.OpenTelemetry.AutoInstrumentation.slnx b/Qyl.OpenTelemetry.AutoInstrumentation.slnx index da060cc4..7d1a04db 100644 --- a/Qyl.OpenTelemetry.AutoInstrumentation.slnx +++ b/Qyl.OpenTelemetry.AutoInstrumentation.slnx @@ -28,6 +28,7 @@ + @@ -35,5 +36,6 @@ + diff --git a/demos/Qyl.RealTcgPublishingDemo/Program.cs b/demos/Qyl.RealTcgPublishingDemo/Program.cs new file mode 100644 index 00000000..83ca8373 --- /dev/null +++ b/demos/Qyl.RealTcgPublishingDemo/Program.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Logs; +using Qyl.OpenTelemetry.AutoInstrumentation; +using Qyl.OpenTelemetry.AutoInstrumentation.Publishing; + +// Proof: the Publishing package emits the binary's Telemetry Capability Graph as a real OTel +// LogRecord at host startup. We attach a processor (the OTel SDK's pipeline — exactly what an OTLP +// exporter would sit behind) and assert the captured record matches the binary's own TCG. +var capturer = new CapturingProcessor(); + +var builder = Host.CreateApplicationBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddOpenTelemetry(options => +{ + options.IncludeFormattedMessage = true; + options.AddProcessor(capturer); +}); +builder.Services.AddQylTelemetryCapabilityGraphPublisher(); + +using (var host = builder.Build()) +{ + await host.StartAsync(); + await host.StopAsync(); +} + +if (!capturer.Found) +{ + await Console.Error.WriteLineAsync("FAIL: no LogRecord carrying qyl.tcg.schema_version was emitted"); + return 1; +} + +Console.WriteLine("event=" + capturer.EventName); +Console.WriteLine("schema_version=" + capturer.SchemaVersion); +Console.WriteLine("capability_count=" + capturer.CapabilityCount); +Console.WriteLine("body_is_tcg_json=" + (capturer.Body == QylTelemetryCapabilityGraph.Json).ToString(CultureInfo.InvariantCulture)); + +var expectedCount = QylTelemetryCapabilityGraph.CapabilityCount.ToString(CultureInfo.InvariantCulture); +var ok = + capturer.EventName == "qyl.telemetry_capability_graph" && + capturer.SchemaVersion == QylTelemetryCapabilityGraph.SchemaVersion && + capturer.CapabilityCount == expectedCount && + capturer.Body == QylTelemetryCapabilityGraph.Json; + +if (!ok) +{ + await Console.Error.WriteLineAsync("FAIL: emitted LogRecord did not match the binary's TCG"); + return 1; +} + +Console.WriteLine("tcg-publishing-ok"); +return 0; + +// Copies the fields out at OnEnd (synchronously, during the log call) so LogRecord pooling cannot +// recycle them out from under the assertions above. +internal sealed class CapturingProcessor : BaseProcessor +{ + public bool Found { get; private set; } + public string? EventName { get; private set; } + public string? SchemaVersion { get; private set; } + public string? CapabilityCount { get; private set; } + public string? Body { get; private set; } + + public override void OnEnd(LogRecord record) + { + if (record.Attributes is null) + return; + + if (Attribute(record, "qyl.tcg.schema_version") is { } schemaVersion) + { + Found = true; + EventName = record.EventId.Name; + SchemaVersion = schemaVersion; + CapabilityCount = Attribute(record, "qyl.tcg.capability_count"); + Body = record.FormattedMessage ?? record.Body; + } + } + + private static string? Attribute(LogRecord record, string key) + { + foreach (var attribute in record.Attributes!) + { + if (attribute.Key == key) + return attribute.Value switch + { + null => null, + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => attribute.Value.ToString(), + }; + } + + return null; + } +} diff --git a/demos/Qyl.RealTcgPublishingDemo/Qyl.RealTcgPublishingDemo.csproj b/demos/Qyl.RealTcgPublishingDemo/Qyl.RealTcgPublishingDemo.csproj new file mode 100644 index 00000000..30c106b5 --- /dev/null +++ b/demos/Qyl.RealTcgPublishingDemo/Qyl.RealTcgPublishingDemo.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + Qyl.RealTcgPublishingDemo + Qyl.RealTcgPublishingDemo + Real proof that the qyl Publishing package emits the Telemetry Capability Graph as a true OpenTelemetry LogRecord at host startup. + + + + + + + + + + + + diff --git a/docs/TELEMETRY_CAPABILITY_GRAPH.md b/docs/TELEMETRY_CAPABILITY_GRAPH.md index 175cd4ee..881ecc48 100644 --- a/docs/TELEMETRY_CAPABILITY_GRAPH.md +++ b/docs/TELEMETRY_CAPABILITY_GRAPH.md @@ -1,8 +1,8 @@ # Telemetry Capability Graph (TCG) — exchange spec -> Status: **v0.1.0-draft.** The generator (pillar 2) ships; the runtime publication channel and the -> queryable endpoint below are the next First-Light steps, marked **(planned)**. Document -> current-tree truth — do not describe a planned channel as if it exists. +> Status: **v0.1.0-draft.** The generator (pillar 2) and the runtime OTel-`LogRecord` channel ship; the +> static build artifact and the remote queryable endpoint below are the next steps, marked **(planned)**. +> Document current-tree truth — do not describe a planned channel as if it exists. ## Why this exists @@ -81,16 +81,16 @@ values are runtime-only). 1. **In-binary constant + public accessor (shipped).** `QylTelemetryCapabilityGraph.Json` / `.SchemaVersion` / `.CapabilityCount`. The authoritative source; every other channel is derived from it. -2. **OTel resource log at boot (planned).** Emit the document once at process start as a single OTLP - `LogRecord` so any collector/backend ingests it through the normal logs pipeline — no qyl-specific - protocol. Proposed mapping: - - `LogRecord.EventName` = `qyl.telemetry_capability_graph` - - `LogRecord.Body` = the TCG JSON (string) - - `LogRecord.SeverityNumber` = INFO +2. **OTel LogRecord at boot (shipped).** The `Qyl.OpenTelemetry.AutoInstrumentation.Publishing` package's + `AddQylTelemetryCapabilityGraphPublisher()` registers a hosted service that emits the document once at + host startup through `ILogger` — so when the app has OpenTelemetry logging + an OTLP exporter wired, it + becomes a true OTLP `LogRecord`, and the exporter stays app-owned (the package takes no OpenTelemetry + SDK dependency). Mapping: + - `LogRecord.EventId.Name` = `qyl.telemetry_capability_graph` + - `LogRecord.Body` = the TCG JSON (string, via the log formatter) + - severity = `Information` - `LogRecord.Attributes`: `qyl.tcg.schema_version`, `qyl.tcg.capability_count` - - **Resource** attributes (so the surface is discoverable on *every* export, cheaply, without the - full body): `qyl.tcg.schema_version`, `qyl.tcg.capability_count`. The full body stays on the - one log record, not on the resource, to avoid per-batch bloat. + - Proven end-to-end by `demos/Qyl.RealTcgPublishingDemo` (`tools/verify-tcg-publishing-demo.py`). 3. **Static build artifact (planned).** `app.telemetry-manifest.json` written at build time (MSBuild step extracting the constant) for CI / compliance / offline consumers. 4. **Queryable surface (partial).** The public accessor above already returns the document in-process; diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Shipped.txt b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Shipped.txt new file mode 100644 index 00000000..7dc5c581 --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Unshipped.txt b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Unshipped.txt new file mode 100644 index 00000000..6527bd75 --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/PublicAPI.Unshipped.txt @@ -0,0 +1,3 @@ +#nullable enable +Qyl.OpenTelemetry.AutoInstrumentation.Publishing.QylTelemetryCapabilityGraphPublishingExtensions +static Qyl.OpenTelemetry.AutoInstrumentation.Publishing.QylTelemetryCapabilityGraphPublishingExtensions.AddQylTelemetryCapabilityGraphPublisher(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/Qyl.OpenTelemetry.AutoInstrumentation.Publishing.csproj b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/Qyl.OpenTelemetry.AutoInstrumentation.Publishing.csproj new file mode 100644 index 00000000..c99509ef --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/Qyl.OpenTelemetry.AutoInstrumentation.Publishing.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + Qyl.OpenTelemetry.AutoInstrumentation.Publishing + Qyl.OpenTelemetry.AutoInstrumentation.Publishing + Qyl.OpenTelemetry.AutoInstrumentation.Publishing + Publishes a qyl binary's Telemetry Capability Graph (TCG) as a single OpenTelemetry LogRecord at host startup. Opt-in via AddQylTelemetryCapabilityGraphPublisher(); emits through ILogger so the OTLP exporter stays app-owned and this package takes no OpenTelemetry SDK dependency. + + + + + + + + + + + + + diff --git a/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/QylTelemetryCapabilityGraphPublishing.cs b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/QylTelemetryCapabilityGraphPublishing.cs new file mode 100644 index 00000000..cd3af41c --- /dev/null +++ b/src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/QylTelemetryCapabilityGraphPublishing.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Qyl.OpenTelemetry.AutoInstrumentation.Publishing; + +/// +/// Registers TCG publishing: emits this binary's Telemetry Capability Graph as a single +/// OpenTelemetry LogRecord at host startup (North Star pillar 3 — the open exchange channel). +/// +public static class QylTelemetryCapabilityGraphPublishingExtensions +{ + /// + /// Publish the binary's Telemetry Capability Graph (TCG) once at host startup as an + /// Information log with event name qyl.telemetry_capability_graph: the TCG JSON is the + /// log body and qyl.tcg.schema_version / qyl.tcg.capability_count are attributes (see + /// docs/TELEMETRY_CAPABILITY_GRAPH.md → publication channel 2). When the app has OpenTelemetry + /// logging with an OTLP exporter configured, this becomes a true OTLP LogRecord. The exporter stays + /// app-owned — this package emits through and takes no OpenTelemetry SDK dependency. + /// + /// The service collection to register the publisher on. + /// The same instance, for chaining. + public static IServiceCollection AddQylTelemetryCapabilityGraphPublisher(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddHostedService(); + return services; + } +} + +/// +/// Emits the binary's Telemetry Capability Graph exactly once, when the host starts. AOT/trim-clean: +/// the log state is a fixed attribute array and the body is a constant string — no reflection. +/// +internal sealed class TelemetryCapabilityGraphPublisher : IHostedService +{ + private const string EventName = "qyl.telemetry_capability_graph"; + private static readonly EventId PublishEvent = new(0, EventName); + + private readonly ILogger _logger; + + public TelemetryCapabilityGraphPublisher(ILogger logger) + => _logger = logger; + + public Task StartAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + var attributes = new[] + { + new KeyValuePair("qyl.tcg.schema_version", QylTelemetryCapabilityGraph.SchemaVersion), + new KeyValuePair("qyl.tcg.capability_count", QylTelemetryCapabilityGraph.CapabilityCount), + }; + + // Body = the TCG JSON; attributes = schema version + capability count; event name above. The + // OpenTelemetry logging bridge maps the state's key/value pairs to LogRecord attributes and the + // formatter output to the LogRecord body. + _logger.Log( + LogLevel.Information, + PublishEvent, + attributes, + exception: null, + formatter: static (_, _) => QylTelemetryCapabilityGraph.Json); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/tools/verify-tcg-publishing-demo.py b/tools/verify-tcg-publishing-demo.py new file mode 100644 index 00000000..2a0fe5d0 --- /dev/null +++ b/tools/verify-tcg-publishing-demo.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import subprocess +from pathlib import Path + +from verify_helpers import clean_env + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = ROOT / "demos" / "Qyl.RealTcgPublishingDemo" / "Qyl.RealTcgPublishingDemo.csproj" + + +def fail(message: str) -> None: + raise SystemExit(message) + + +def main() -> None: + env = clean_env() + try: + completed = subprocess.run( + ["dotnet", "run", "--project", str(PROJECT), "-c", "Release", "-v", "quiet"], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + timeout=600, + ) + except subprocess.TimeoutExpired as timed_out: + fail( + "tcg publishing demo timed out (the run-once host should emit the TCG and exit)\n" + f"timeout={timed_out.timeout}s\nstdout={timed_out.stdout}\nstderr={timed_out.stderr}" + ) + if completed.returncode != 0: + fail( + "tcg publishing demo failed\n" + f"exit={completed.returncode}\nstdout={completed.stdout}\nstderr={completed.stderr}" + ) + + stdout = completed.stdout + # The demo attaches an OTel log processor (where an OTLP exporter would sit) and asserts the + # emitted LogRecord matches the binary's own TCG before printing the marker below. + for required in ( + "event=qyl.telemetry_capability_graph", + "body_is_tcg_json=True", + "tcg-publishing-ok", + ): + if required not in stdout: + fail(f"tcg publishing demo missing expected output: {required!r}\nstdout={stdout}") + + print("tcg-publishing-demo-ok") + + +if __name__ == "__main__": + main()