Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
73 changes: 65 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,61 @@

## Mission

This repository is the runtime AOT auto-instrumentation lane for qyl. Keep it separate from:
This repository is the runtime AOT auto-instrumentation lane for qyl, evolving into a
**self-describing observability substrate**. The foundation is unchanged: .NET 10
NativeAOT-compatible zero-code instrumentation through managed build assets, source generation,
DiagnosticListener consumption, and module-initializer boot. The direction is the North Star
below.

- semantic-convention package generation,
- the old CLR-profiler/OpenTelemetry auto-instrumentation substrate,
- unrelated compile-time tracing experiments.
Keep this repository separate from:

The product goal is .NET 10 NativeAOT-compatible zero-code instrumentation through managed
build assets, source generation, DiagnosticListener consumption, and module-initializer boot.
- semantic-convention package generation (`Qyl.OpenTelemetry.SemanticConventions` is a *referenced*
vocabulary package, not generated here),
- the old CLR-profiler/OpenTelemetry auto-instrumentation substrate,
- unrelated compile-time tracing experiments (the `experiment/` and `spike/` trees stay outside
the `.slnx` / production build graph — see `docs/experiments/`).

## North Star — declare and prove the whole stack

Every observability tool today is **pull-by-observation**: a backend learns what a service emits
by receiving samples over time, and never knows whether it has seen the whole surface. qyl has a
capability none of them have — because instrumentation is source-generated interceptors + a static
contract + a referenced semconv registry + (incrementally) DTO inference, **the complete set of
telemetry a binary can ever produce is a compile-time-derivable fact, with provenance.**

The substrate goal: every qyl binary ships a complete, machine-readable **Telemetry Capability
Graph (TCG)** — the full possible OpenTelemetry surface for that exact binary, each capability
tagged compile-time-owned vs runtime-valued — and *proves* it by self-hosting (instrumenting its
own pipeline with its own mechanism, zero extra code). Any external entity consumes the TCG to know
the entire stack before a span is sampled. The contract becomes the shared semantic graph; an OTLP
backend is just one consumer.

Three pillars:

1. **Self-host (the proof).** qyl instruments qyl with qyl — `QylSelfTelemetry` /
`SemConvConformanceProcessor` are the seed; the binary observing itself is how "declared TCG ==
runtime reality" is checked.
2. **Compile-time-complete TCG (the artifact).** A generated, deterministic manifest of every span
name / metric / attribute key the binary can produce, with provenance. Only compile-time
instrumentation can enumerate this honestly.
3. **Open exchange (the reach).** The TCG published vendor-neutrally (OTel Resource/Scope + a static
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 —
`TelemetryCapabilityGraphGenerator` bakes the TCG into the core assembly (generated
`Internal.QylTelemetryCapabilityGraphData`, gated to the core assembly like `SemConvRegistryGenerator`),
exposed publicly as `QylTelemetryCapabilityGraph.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.

**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
the TCG, not a failure to delete. Semantic ownership trends toward compile time; runtime owns
observations. "Missing values stay missing; never synthesize" still governs every lane, including
any future inference.

## Clean slate before work

Expand Down Expand Up @@ -69,6 +116,10 @@ files left behind.
flags switch redacted to raw); `db.query.text` sits behind the upstream
`SET_DBSTATEMENT_FOR_TEXT` flags; the conformance processor behind
`QYL_CONFORMANCE_ENABLED=1`.
6. The **Telemetry Capability Graph** (`TelemetryCapabilityGraphGenerator`) bakes the instrumentation
contract into the core assembly as `QylTelemetryCapabilityGraph.Json` — the binary's declared,
provenance-tagged telemetry surface (North Star pillar 2). Generated from the same contract data,
so it is a pure function of its inputs and never drifts from what the lanes above actually emit.

Key gotcha: a bare `ProjectReference` to the runtime project does NOT flow analyzer/build
assets — `PackageReference` is the supported zero-code path. The dogfood path references the
Expand All @@ -79,8 +130,9 @@ runtime project, the generator project as analyzer, and the core targets file ex

The accepted qyl instrumentation mechanisms here are exactly these: ordinary C# compiled into
the app, source-generated interceptors, build-transitive assets, module-initializer activation,
BCL telemetry primitives, and public library diagnostic payloads. Build every integration out
of these.
BCL telemetry primitives, and public library diagnostic payloads. Build every integration — and
the entire self-describing surface (the TCG, self-host telemetry, any future inference) — out of
these and nothing else.

The following are the old substrate this repo deliberately replaced; reintroducing any of them
into product code or package assets is out of bounds:
Expand Down Expand Up @@ -121,6 +173,8 @@ Generated/evidence surfaces include:
- source-generator verified snapshots,
- OTLP/verified fixture files,
- generated coverage matrix,
- the Telemetry Capability Graph (`QylTelemetryCapabilityGraph.g.cs`, from the instrumentation
contract via `TelemetryCapabilityGraphGenerator`),
- package build/buildTransitive generated assets.

If a generated file is obsolete, delete it only with the generator/input change that makes it
Expand All @@ -129,6 +183,8 @@ obsolete.
## Documentation rules

- Document current-tree behavior, not old PR state or ceremonial progress claims.
- The North Star is direction, not a claim of completion — mark what is shipped vs next, and never
describe a future pillar as if it already exists.
- Do not claim a tag or GitHub Release is current without checking its target commit.
- Keep README user-facing and operational.
- Keep CHANGELOG synthetic and useful for continuation, not a raw commit dump.
Expand All @@ -144,6 +200,7 @@ Use the narrowest verifier that covers the changed surface:
| ProjectReference behavior | `python3 tools/verify-projectreference-behavior.py` |
| Source generator snapshots | `python3 tools/verify-generator-snapshots.py` |
| Source interceptor behavior | `python3 tools/verify-source-interceptor-consumer.py` |
| Telemetry Capability Graph | `dotnet build src/Qyl.OpenTelemetry.AutoInstrumentation/Qyl.OpenTelemetry.AutoInstrumentation.csproj` (emits `QylTelemetryCapabilityGraph.g.cs`) |
| NativeAOT smoke | `bash tools/smoketest.sh` |
| OTLP fixtures | `python3 tools/verify-otlp-fixtures.py` and `python3 tools/verify-otlp-collector-fixtures.py` |
| Whole repo handoff | `python3 tools/verify-aot-autoinstrumentation-goal.py` |
Expand Down
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
Pack="true"
PackagePath=""
Visible="false" />
<!-- EXPERIMENT (precompilation in-place): force the NIGHTLY host compiler (5.9.0) on every
project so the generator's RegisterPreCompilationSourceOutput call is loadable (otherwise
CS9057 — the SDK in-box compiler 5.6.0 refuses the 5.9.0-referencing analyzer). This is the
non-shippable cost made concrete: every consumer would have to pin this too. -->
<PackageReference Include="Microsoft.Net.Compilers.Toolset" PrivateAssets="all" />
<PackageReference Include="ANcpLua.Analyzers"
PrivateAssets="all"
IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
Expand Down
10 changes: 8 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@
<PackageVersion Include="log4net" Version="3.3.1" />
<!-- MassTransit 9+ requires a runtime license; the verifier stays on the no-secret 8.x line. -->
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.10" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<!-- EXPERIMENT (precompilation in-place): forced to the nightly floor — the nightly
Microsoft.CodeAnalysis.CSharp transitively requires Analyzers >= 5.9.0-1.26319.6 (NU1605
downgrade against the stable 5.3.0). One nightly pull drags the whole nightly Roslyn graph. -->
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0-1.26319.6" />
<!-- EXPERIMENT (precompilation in-place): nightly Roslyn main carries RegisterPreCompilationSourceOutput.
No stable nuget.org release has it (latest stable Toolset = 5.3.0, 2026-03-10). -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0-1.26324.7" />
<PackageVersion Include="Microsoft.Net.Compilers.Toolset" Version="5.9.0-1.26324.7" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
Expand Down
124 changes: 124 additions & 0 deletions docs/TELEMETRY_CAPABILITY_GRAPH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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.

## Why this exists

Every observability tool today is **pull-by-observation**: a backend learns what a service emits by
receiving samples over time, and never knows whether it has seen the whole surface. A qyl binary can
do the opposite — **declare** its complete possible OpenTelemetry surface, deterministically, before
a single span is sampled, because that surface is a compile-time fact (source-generated interceptors
+ a static contract + a referenced semconv vocabulary). The TCG is that declaration in a
vendor-neutral form any external entity can consume. It is generated, never hand-written, so it
cannot drift from what the binary actually emits.

The schema is [`schema/telemetry-capability-graph.schema.json`](schema/telemetry-capability-graph.schema.json)
(JSON Schema 2020-12). The producer is `TelemetryCapabilityGraphGenerator`, which bakes the document into the core assembly
as the generated constant `Internal.QylTelemetryCapabilityGraphData.Json`, exposed publicly as
`QylTelemetryCapabilityGraph.Json` (with `.SchemaVersion` and `.CapabilityCount`).

## Document shape

```jsonc
{
"schemaVersion": "0.1.0-draft",
"generator": "Qyl.OpenTelemetry.AutoInstrumentation.SourceGenerators.TelemetryCapabilityGraphGenerator",
"capabilityCount": 60,
"signals": { "traces": 44, "metrics": 10, "logs": 5 },
"capabilities": [
{
"id": "OTEL_DOTNET_AUTO_CONTRACT_003", // stable cross-version join key
"key": "signals.traces.ASPNETCORE",
"signal": "traces", // traces | metrics | logs | none
"lane": "RuntimePublicTelemetry",
"status": "Implemented",
"payloadAccess": "TypedPublic", // TypedPublic | ReflectionRequired | NotApplicable
"provenance": "runtime", // compile-time | runtime | control | unsupported | unknown
"instrumentationId": "ASPNETCORE",
"supportedVersions": "*",
"owner": "Qyl public telemetry listeners/meters",
"libraries": ["ASP.NET Core"],
"attributes": [] // declared semconv keys; populated as lanes fill the contract
}
]
}
```

## The `provenance` vocabulary (the load-bearing field)

This is the field a sampling consumer can never recover. It answers "where does each capability's
meaning come from?":

| provenance | meaning | derived from lane |
|---|---|---|
| `compile-time` | attribute keys are owned at compile time by an interceptor or framework-init | `SourceInterceptor`, `FrameworkInitialization` |
| `runtime` | attribute *values* are read at runtime from typed payloads by a listener | `RuntimePublicTelemetry`, `OfficialLibraryHook` |
| `control` | a control surface (env var / option), not a span/metric/log itself | `EnvironmentControl`, `InstrumentationOption` |
| `unsupported` | not available on the NativeAOT substrate | `UnsupportedNativeAot` |
| `unknown` | catch-all; consumers MUST map unrecognized values here | — |

Provenance is the bridge between the North Star halves: *semantic ownership trends to compile time,
runtime owns observations.* `runtime` capabilities are exactly where DiagnosticListeners are
load-bearing and cannot be deleted (`docs/experiments/precompilation-verdict.md`: ~95% of attribute
values are runtime-only).

## Versioning & compatibility

- `schemaVersion` is semver. Within a **major**, changes are additive: new object properties, new
enum members, new capabilities. A `-draft` pre-release may still change shape.
- **Producers** emit all `required` fields and may add properties.
- **Consumers** MUST: ignore unknown object properties; treat unknown `signal` / `lane` / `status` /
`payloadAccess` / `provenance` members as the documented catch-all (`unknown` for provenance,
otherwise pass-through); join across versions on `capability.id`, never on array index.
- A breaking change (removing/renaming a field, changing a field's meaning) bumps the **major** and
is announced in the repo CHANGELOG.

## Publication channels (how an external entity gets the TCG)

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
- `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.
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;
exposing it over the wire (a diagnostic endpoint or CLI) so a running service answers "what is your
complete telemetry surface?" remotely is still planned.

## Consumer patterns (what the TCG unlocks)

- **Backend pre-provisioning.** Read the TCG on first contact; create dashboards/alerts for the full
declared surface before span #1 arrives. Group by `signal`; size cardinality budgets from
`provenance` (runtime keys carry values, compile-time keys are fixed).
- **Collector validation.** A collector holding the TCG drops/flags telemetry whose attribute keys
are not in the declared surface — edge-enforced anti-drift and anti-cardinality. (Pairs with the
existing `SemConvConformanceProcessor`, which does the in-process version under
`QYL_CONFORMANCE_ENABLED=1`.)
- **CI gate / typed telemetry API.** Diff a PR's TCG against the merge base: a new capability is a new
observable surface; a removed one is a breaking change for downstream consumers. Telemetry becomes
a versioned contract, joined on `capability.id`.
- **Cross-service typing.** Service A's TCG declares what it emits; a mesh or service B knows the
upstream telemetry shape statically.

## Relationship to the generator

The TCG is generated output (see AGENTS.md → *Generated and evidence files*): its single source of
truth is `TelemetryCapabilityGraphGenerator` + `InstrumentationContract`. To change the TCG, change
the contract or the generator and rebuild — never hand-edit the emitted constant. Validate with:

```bash
dotnet build src/Qyl.OpenTelemetry.AutoInstrumentation/Qyl.OpenTelemetry.AutoInstrumentation.csproj \
-p:EmitCompilerGeneratedFiles=true
# then validate the emitted constant against schema/telemetry-capability-graph.schema.json
```
Loading
Loading