Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageVersion Include="MongoDB.Driver" Version="3.9.0" />
<PackageVersion Include="MySql.Data" Version="9.7.0" />
Expand Down
2 changes: 2 additions & 0 deletions Qyl.OpenTelemetry.AutoInstrumentation.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
<Project Path="demos/Qyl.RealRedisDemo/Qyl.RealRedisDemo.csproj" />
<Project Path="demos/Qyl.RealSqlClientDemo/Qyl.RealSqlClientDemo.csproj" />
<Project Path="demos/Qyl.RealSqliteDemo/Qyl.RealSqliteDemo.csproj" />
<Project Path="demos/Qyl.RealTcgPublishingDemo/Qyl.RealTcgPublishingDemo.csproj" />
<Project Path="demos/Qyl.RealWcfClientDemo/Qyl.RealWcfClientDemo.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation/Qyl.OpenTelemetry.AutoInstrumentation.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.DiagnosticListeners/Qyl.OpenTelemetry.AutoInstrumentation.DiagnosticListeners.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.EntityFrameworkCore/Qyl.OpenTelemetry.AutoInstrumentation.EntityFrameworkCore.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.Hosting/Qyl.OpenTelemetry.AutoInstrumentation.Hosting.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.SqlClient/Qyl.OpenTelemetry.AutoInstrumentation.SqlClient.csproj" />
<Project Path="src/Qyl.OpenTelemetry.AutoInstrumentation.Publishing/Qyl.OpenTelemetry.AutoInstrumentation.Publishing.csproj" />
<Project Path="tests/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots/Fixture/Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.Snapshots.Fixture.csproj" />
</Solution>
97 changes: 97 additions & 0 deletions demos/Qyl.RealTcgPublishingDemo/Program.cs
Original file line number Diff line number Diff line change
@@ -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<LogRecord>
{
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;
}
}
20 changes: 20 additions & 0 deletions demos/Qyl.RealTcgPublishingDemo/Qyl.RealTcgPublishingDemo.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Qyl.RealTcgPublishingDemo</RootNamespace>
<AssemblyName>Qyl.RealTcgPublishingDemo</AssemblyName>
<Description>Real proof that the qyl Publishing package emits the Telemetry Capability Graph as a true OpenTelemetry LogRecord at host startup.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Qyl.OpenTelemetry.AutoInstrumentation.Publishing\Qyl.OpenTelemetry.AutoInstrumentation.Publishing.csproj" />
</ItemGroup>

</Project>
24 changes: 12 additions & 12 deletions docs/TELEMETRY_CAPABILITY_GRAPH.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#nullable enable
Original file line number Diff line number Diff line change
@@ -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!
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Qyl.OpenTelemetry.AutoInstrumentation.Publishing</RootNamespace>
<AssemblyName>Qyl.OpenTelemetry.AutoInstrumentation.Publishing</AssemblyName>
<PackageId>Qyl.OpenTelemetry.AutoInstrumentation.Publishing</PackageId>
<Description>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.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Qyl.OpenTelemetry.AutoInstrumentation\Qyl.OpenTelemetry.AutoInstrumentation.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Qyl.OpenTelemetry.AutoInstrumentation.Publishing;

/// <summary>
/// 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).
/// </summary>
public static class QylTelemetryCapabilityGraphPublishingExtensions
{
/// <summary>
/// Publish the binary's Telemetry Capability Graph (TCG) once at host startup as an
/// <c>Information</c> log with event name <c>qyl.telemetry_capability_graph</c>: the TCG JSON is the
/// log body and <c>qyl.tcg.schema_version</c> / <c>qyl.tcg.capability_count</c> are attributes (see
/// <c>docs/TELEMETRY_CAPABILITY_GRAPH.md</c> → 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 <see cref="ILogger"/> and takes no OpenTelemetry SDK dependency.
/// </summary>
/// <param name="services">The service collection to register the publisher on.</param>
/// <returns>The same <paramref name="services"/> instance, for chaining.</returns>
public static IServiceCollection AddQylTelemetryCapabilityGraphPublisher(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddHostedService<TelemetryCapabilityGraphPublisher>();
return services;
}
}

/// <summary>
/// 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.
/// </summary>
internal sealed class TelemetryCapabilityGraphPublisher : IHostedService
{
private const string EventName = "qyl.telemetry_capability_graph";
private static readonly EventId PublishEvent = new(0, EventName);

private readonly ILogger<TelemetryCapabilityGraphPublisher> _logger;

public TelemetryCapabilityGraphPublisher(ILogger<TelemetryCapabilityGraphPublisher> logger)
=> _logger = logger;

public Task StartAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
return Task.FromCanceled(cancellationToken);

var attributes = new[]
{
new KeyValuePair<string, object?>("qyl.tcg.schema_version", QylTelemetryCapabilityGraph.SchemaVersion),
new KeyValuePair<string, object?>("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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
55 changes: 55 additions & 0 deletions tools/verify-tcg-publishing-demo.py
Original file line number Diff line number Diff line change
@@ -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()