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()