|
| 1 | +# Application Name Telemetry |
| 2 | + |
| 3 | +> **Status:** Implemented · **Tracking issue:** [#3216](https://github.com/Azure/data-api-builder/issues/3216) |
| 4 | +
|
| 5 | +## Summary |
| 6 | + |
| 7 | +Data API builder (DAB) embeds a compact, anonymous **usage-telemetry token** into the `Application Name` property of the connection strings it uses to reach SQL Server, Azure SQL, Azure SQL Data Warehouse (DWSQL), and PostgreSQL. Because `Application Name` is surfaced on the database side (for example in `sys.dm_exec_sessions.program_name`), this lets the team understand — **in aggregate and without any per-customer identifiers** — which DAB version is running and which features are enabled, using telemetry the database already collects. |
| 8 | + |
| 9 | +The token has the shape: |
| 10 | + |
| 11 | +```text |
| 12 | +dab_oss_<version>+<context>|<runtime>|<entity>+ |
| 13 | +``` |
| 14 | + |
| 15 | +Example (an MSSQL pool, REST + GraphQL on, Static Web Apps auth): |
| 16 | + |
| 17 | +```text |
| 18 | +dab_oss_2.0.0+XXSX|110000M1M000MMMMMWMM|100?111001110?+ |
| 19 | +``` |
| 20 | + |
| 21 | +It is opt-out (`DAB_TELEMETRY_APPNAME_OPT_OUT=1`), carries no secrets or identifiers, and is purely additive to the existing `Application Name` value. |
| 22 | + |
| 23 | +## Motivation |
| 24 | + |
| 25 | +DAB ships as an open-source container that customers run anywhere. We have very little visibility into how it is configured or which features are exercised. The connection's `Application Name` is a standard, low-cost signal that the database side already records, so encoding a small feature fingerprint there gives us aggregate usage insight with: |
| 26 | + |
| 27 | +- **no new endpoints, services, or network calls,** |
| 28 | +- **no per-customer data,** and |
| 29 | +- **a single, easy place to query** on the database side. |
| 30 | + |
| 31 | +### Goals |
| 32 | + |
| 33 | +- Encode the DAB **version** and a **feature fingerprint** of a deployment into `Application Name`. |
| 34 | +- Make the token **queryable and decodable** so the data can be aggregated and read back. |
| 35 | +- Be **safe by construction**: no secrets, no identifiers, easy to opt out of, and additive to any user-supplied `Application Name`. |
| 36 | +- Avoid changing **connection-pool behavior** (see [Pooling model](#pooling-model-why-some-fields-are-x)). |
| 37 | + |
| 38 | +### Non-goals |
| 39 | + |
| 40 | +- **Per-request** telemetry (which API was called, which entity, which role). Those facets are not knowable when a pooled connection opens and belong in DAB's request-level telemetry (OpenTelemetry / Application Insights), not the `Application Name`. |
| 41 | +- Telemetry for **MySQL** and **Cosmos DB**. MySQL does not get a payload, and Cosmos connection strings are left untouched. |
| 42 | + |
| 43 | +## Background: `Application Name` and connection pooling |
| 44 | + |
| 45 | +`Application Name` is a first-class keyword in both the SQL Server and Npgsql connection-string builders, and it is part of the **connection-pool key**. Two connection strings that differ only in `Application Name` produce two separate pools. |
| 46 | + |
| 47 | +Two consequences shape the design: |
| 48 | + |
| 49 | +1. The token is computed **once per data source at configuration load** and is constant for the lifetime of that data source, so embedding it does **not** create additional pools per request — every request to a data source reuses the same `Application Name`. |
| 50 | +2. Each data source already has its own connection string (its own pool), so the token is naturally emitted **per pool**. |
| 51 | + |
| 52 | +DAB already appended a plain `dab_oss_<version>` user agent to the `Application Name`; this feature replaces that plain value with the richer, decodable token (while preserving any user-supplied `Application Name` as a comma-separated prefix). |
| 53 | + |
| 54 | +## The token format |
| 55 | + |
| 56 | +```text |
| 57 | +dab_oss_<version>+<context>|<runtime>|<entity>+ |
| 58 | +``` |
| 59 | + |
| 60 | +- `dab_oss_` — a fixed marker (`ProductInfo.DAB_USER_AGENT_MARKER`) used to locate the token and to decode it. |
| 61 | +- `<version>` — the product version `Major.Minor.Patch` (from `ProductInfo.DAB_USER_AGENT`). The telemetry is always based on the product version, so it is independent of any host label (see [Hosted label](#hosted-label-dab_app_name_env)). |
| 62 | +- The payload is wrapped in `+ ... +` and split into **three `|`-delimited sections**: `context`, `runtime`, and `entity`. |
| 63 | + |
| 64 | +> **Note on the issue's example.** The original issue's example string listed a fourth `general` segment, but the issue only defined three settings tables (Context, Runtime, Entity). `general` was never defined, so the implementation uses the three authoritative sections only. |
| 65 | +
|
| 66 | +Each position in a section is a single character drawn from a small alphabet. The shared sentinel values are: |
| 67 | + |
| 68 | +| Char | Meaning | |
| 69 | +| --- | --- | |
| 70 | +| `0` | feature present and off / false | |
| 71 | +| `1` | feature present and on / true | |
| 72 | +| `M` | **missing** — the config section that would answer this is absent | |
| 73 | +| `X` | **not applicable** — not knowable when the pool opens (per-request fields) | |
| 74 | +| `?` | **not supported** — the concept is not yet modeled in DAB | |
| 75 | + |
| 76 | +A few positions use field-specific letters instead (Source and Auth provider), described below. |
| 77 | + |
| 78 | +### Context section (4 characters) |
| 79 | + |
| 80 | +Identifies *what kind of connection* this is. Only `Source` is knowable when a pooled connection opens; the rest are per-request and therefore `X` (see [Pooling model](#pooling-model-why-some-fields-are-x)). |
| 81 | + |
| 82 | +| Pos | Field | Encoding | |
| 83 | +| --- | --- | --- | |
| 84 | +| 1 | Protocol | always `X` (per-request: REST / GraphQL / MCP) | |
| 85 | +| 2 | Object | always `X` (per-request: table / view / stored-proc / document) | |
| 86 | +| 3 | Source | the database engine of this data source (see table) | |
| 87 | +| 4 | Role | always `X` (per-request: anonymous / authenticated / custom) | |
| 88 | + |
| 89 | +**Source map:** `MSSQL -> S`, `DWSQL -> D`, `PostgreSQL -> P`, `MySQL -> M`, `Cosmos -> C`, and `X` when there is no live data source (for example the CLI, which has no open connection). |
| 90 | + |
| 91 | +### Runtime section (20 characters) |
| 92 | + |
| 93 | +A fingerprint of the **global** `runtime` configuration. Each position is `0` / `1` / `M` unless noted. |
| 94 | + |
| 95 | +| Pos | Setting | |
| 96 | +| --- | --- | |
| 97 | +| 1 | `runtime.rest.enabled` | |
| 98 | +| 2 | `runtime.graphql.enabled` | |
| 99 | +| 3 | `runtime.mcp.enabled` | |
| 100 | +| 4 | `runtime.host.mode` (`0` = Development, `1` = Production, `M` = missing) | |
| 101 | +| 5 | `data-source-files` present (multi-database) | |
| 102 | +| 6 | `azure-key-vault` configured | |
| 103 | +| 7 | `runtime.health.enabled` | |
| 104 | +| 8 | `runtime.cache.enabled` | |
| 105 | +| 9 | `runtime.cache.level-2.enabled` | |
| 106 | +| 10 | data source uses on-behalf-of (OBO) auth | |
| 107 | +| 11 | auto-entities present | |
| 108 | +| 12 | `runtime.rest.request-body-strict` | |
| 109 | +| 13 | `runtime.graphql.multiple-mutations.create.enabled` | |
| 110 | +| 14 | `runtime.telemetry.open-telemetry.enabled` | |
| 111 | +| 15 | `runtime.telemetry.application-insights.enabled` | |
| 112 | +| 16 | `runtime.telemetry.azure-log-analytics.enabled` | |
| 113 | +| 17 | `runtime.telemetry.file.enabled` (file sink) | |
| 114 | +| 18 | `runtime.host.authentication.provider` (letter, see below) | |
| 115 | +| 19 | embeddings enabled | |
| 116 | +| 20 | embeddings endpoint configured | |
| 117 | + |
| 118 | +**Auth provider letters (position 18):** `U` = Unauthenticated/Simulator-disabled, `S` = Simulator, `W` = StaticWebApps, `A` = AppService, `E` = EntraID / AzureAD, `C` = a custom JWT provider, `M` = no authentication section. The single-letter mapping is a DAB-chosen convention (the issue gave the alphabet without a legend); it is trivial to adjust because encode and decode share one table. |
| 119 | + |
| 120 | +### Entity section (14 characters) |
| 121 | + |
| 122 | +An "**is any entity using X?**" fingerprint computed across the (merged) entity set. Each position is `0` / `1` / `M`; positions 4 and 14 may also be `?` because they are not yet modeled. |
| 123 | + |
| 124 | +| Pos | "Any entity …" | |
| 125 | +| --- | --- | |
| 126 | +| 1 | is a table | |
| 127 | +| 2 | is a view | |
| 128 | +| 3 | is a stored procedure | |
| 129 | +| 4 | is an MCP persisted document (`?` — not modeled) | |
| 130 | +| 5 | has caching enabled | |
| 131 | +| 6 | has REST enabled | |
| 132 | +| 7 | has GraphQL enabled | |
| 133 | +| 8 | exposes MCP DML tools | |
| 134 | +| 9 | exposes an MCP custom tool | |
| 135 | +| 10 | uses a custom role (not `anonymous` / `authenticated`) | |
| 136 | +| 11 | uses an item-level policy | |
| 137 | +| 12 | has a description | |
| 138 | +| 13 | has relationships | |
| 139 | +| 14 | uses parameter embedding (`?` — not modeled) | |
| 140 | + |
| 141 | +`M` here means "no entities at all," distinguishing an empty deployment from one whose entities simply do not use a feature. |
| 142 | + |
| 143 | +## Design |
| 144 | + |
| 145 | +### Encoder / decoder |
| 146 | + |
| 147 | +A single class, `ApplicationNameTelemetry` (in `Azure.DataApiBuilder.Config.Telemetry`), owns the format: |
| 148 | + |
| 149 | +- `EncodeTelemetryString(config, liveDataSource)` produces the pure `dab_oss_<version>+...+` token. It is **independent of the opt-out switch and of any host label** — it always emits the full payload, which is why the CLI uses it for inspection. |
| 150 | +- `BuildApplicationNameSegment(config, liveDataSource)` produces what is actually embedded: it honors the opt-out switch and prepends any host label. |
| 151 | +- `Decode(applicationName)` turns a token back into human-readable lines and is tolerant of truncation, a missing trailing delimiter, an absent payload, and extra (newer) flags. |
| 152 | + |
| 153 | +Encode and decode are driven by **one ordered list of settings per section** (`_contextSettings`, `_runtimeSettings`, `_entitySettings`). Each setting knows how to encode itself and how to describe a decoded character, so the two directions can never drift apart, and adding a flag is a one-line, append-only change. |
| 154 | + |
| 155 | +### Where and when the token is embedded |
| 156 | + |
| 157 | +The token is woven into connection strings at **configuration load time**, never per request. |
| 158 | + |
| 159 | +- **File / standard load.** `RuntimeConfigLoader.TryParseConfig` post-processes the parsed config and, for every MSSQL / DWSQL / PostgreSQL data source, replaces the `Application Name` with the embedded token. A single public dispatcher, `GetConnectionStringWithApplicationName(connectionString, config, dataSource)`, selects the engine-specific builder (`SqlConnectionStringBuilder` vs `NpgsqlConnectionStringBuilder`); engines without telemetry support return the connection string unchanged. |
| 160 | +- **Hosted / late-config.** The `POST /configuration` endpoint supplies configuration after startup with environment-variable replacement disabled, which bypasses the file-load post-processing. `RuntimeConfigProvider.Initialize` therefore embeds the token itself for every data source after the config is materialized, so hosted deployments — exactly where the `dab_hosted` label matters most — are covered for both the single-connection-string and merged-config endpoint variants. |
| 161 | + |
| 162 | +### Pooling model: why some fields are `X` |
| 163 | + |
| 164 | +The `Application Name` is the pool key. If `Protocol`, `Object`, and `Role` were encoded per request, DAB would need a distinct pool per `(protocol, object, role)` combination (3 × 4 × 3 = 36 per data source, multiplied again per user under OBO), exploding the pool count and harming performance. We therefore adopt **Model A**: encode only what is fixed when the pool opens (`Source`) and emit `X` for the per-request facets. Those per-request dimensions, when needed, belong in DAB's request-level telemetry, not the connection's `Application Name`. |
| 165 | + |
| 166 | +### Global telemetry per pool (multi-database) |
| 167 | + |
| 168 | +In a multi-database deployment each data source is its own pool, so the token is embedded into each. We deliberately encode the **global** runtime and the **complete (merged) entity set** at every pool, rather than scoping the entity fingerprint to the entities of that specific data source. The decisive reason is that **the token carries no deployment-correlation identifier**, so the consumer cannot stitch per-pool slices back into one deployment. Encoding the global picture at every pool means **any single sampled connection is sufficient** to know the deployment's full feature profile — robust to sampling and to rarely-opened pools. (The `Source` character still differs per pool, so the engine mix is preserved.) |
| 169 | + |
| 170 | +### Idempotency |
| 171 | + |
| 172 | +Embedding is idempotent: the engine-specific helpers parse the existing `Application Name` and **skip** if it already contains the `dab_oss_` marker. This guarantees a value can never accumulate a duplicated payload (`...+...+,dab_oss_...+...+`) even if the embed path runs more than once (for example loader post-processing followed by the late-config provider). A user-supplied `Application Name` with no marker is preserved and the token is appended after a comma. |
| 173 | + |
| 174 | +### Opt-out (`DAB_TELEMETRY_APPNAME_OPT_OUT`) |
| 175 | + |
| 176 | +Setting `DAB_TELEMETRY_APPNAME_OPT_OUT=1` reduces the embedded value to **version only** (`dab_oss_<version>`, no payload). Any other value (or unset) leaves telemetry on. The marker is preserved even when opted out so the version remains decodable. |
| 177 | + |
| 178 | +### Hosted label (`DAB_APP_NAME_ENV`) |
| 179 | + |
| 180 | +When `DAB_APP_NAME_ENV` is set (DAB's hosted offering sets it to `dab_hosted`), its value is preserved as a **comma prefix**: `dab_hosted,dab_oss_<version>+...+`. Telemetry is always computed from the product version, so the host label **never suppresses** the token and the `dab_oss_` marker stays intact for decoding. |
| 181 | + |
| 182 | +### CLI: `dab appname` |
| 183 | + |
| 184 | +A new offline command supports inspection without a database: |
| 185 | + |
| 186 | +- `dab appname --config <file>` parses the config and prints the token. Context is emitted as placeholders (no live connection), so the `Source` is `X`. This command performs **no validation and opens no connection** — it is a static inspection tool, and it intentionally always shows the full encoding regardless of the opt-out switch. |
| 187 | +- `dab appname --decode "<token>"` prints a human-readable legend, tolerant of truncation. |
| 188 | +- `-o, --output <file>` writes the result to a file instead of stdout. |
| 189 | + |
| 190 | +### Diagnostic logging |
| 191 | + |
| 192 | +When the token is computed, DAB emits a single Debug log of the **token only** (never the full connection string, which can contain secrets). Because the log level may not be known when connection strings are first computed, the entry is buffered in a shared `LogBuffer` and flushed once the logger is available (at startup, on hot reload, and on the hosted late-config path). The buffer is **bounded** (drop-oldest beyond a cap) so it cannot grow without limit if it is ever left undrained. |
| 193 | + |
| 194 | +## Privacy and security |
| 195 | + |
| 196 | +- The token contains **no connection-string contents, secrets, server names, database names, or customer identifiers** — only the DAB version and boolean/categorical feature flags. |
| 197 | +- It is **opt-out** via `DAB_TELEMETRY_APPNAME_OPT_OUT=1`. |
| 198 | +- The diagnostic log emits the **token**, never the connection string. |
| 199 | + |
| 200 | +## Scope |
| 201 | + |
| 202 | +| Engine | Telemetry token | Notes | |
| 203 | +| --- | --- | --- | |
| 204 | +| SQL Server / Azure SQL (`MSSQL`) | Yes (`Source = S`) | | |
| 205 | +| SQL Data Warehouse (`DWSQL`) | Yes (`Source = D`) | shares the SQL Server builder | |
| 206 | +| PostgreSQL | Yes (`Source = P`) | | |
| 207 | +| MySQL | No | connection string left unchanged | |
| 208 | +| Cosmos DB | No | connection string left unchanged | |
| 209 | + |
| 210 | +## Decoding and observing in production |
| 211 | + |
| 212 | +On the database side, the token appears as the session's program name. For SQL Server: |
| 213 | + |
| 214 | +```sql |
| 215 | +SELECT program_name |
| 216 | +FROM sys.dm_exec_sessions |
| 217 | +WHERE program_name LIKE 'dab_oss%' OR program_name LIKE '%,dab_oss%'; |
| 218 | +``` |
| 219 | + |
| 220 | +A captured token can be decoded back to a legend with `dab appname --decode "<token>"`. |
| 221 | + |
| 222 | +## Testing |
| 223 | + |
| 224 | +- **Encoder / decoder unit tests** for token shape, each section's flag mapping, the Source and auth-provider maps, opt-out, the host-label prefix, and round-trip / truncation-tolerant decoding. |
| 225 | +- **Connection-string injection tests** for MSSQL, DWSQL, and PostgreSQL (including the user-supplied `Application Name` prefix case), and the no-op cases for MySQL / Cosmos. |
| 226 | +- **Multi-database tests** asserting child data sources encode the global runtime and merged entities, and a heterogeneous (MSSQL + PostgreSQL) case asserting the per-pool `Source` character. |
| 227 | +- **Hosted / late-config tests** asserting telemetry is embedded through `RuntimeConfigProvider.Initialize` for the single-source and multi-database cases, plus end-to-end `/configuration` and `/configuration/v2` endpoint tests. |
| 228 | +- **Idempotency test** asserting a re-embed is a no-op (exactly one marker). |
| 229 | +- **`LogBuffer` tests** asserting the bounded drop-oldest behavior and flush-and-drain. |
| 230 | +- **CLI tests** for encode (to file and stdout), decode, the config-not-found error path, and opt-out independence. |
| 231 | + |
| 232 | +## Extensibility |
| 233 | + |
| 234 | +- **Adding a flag.** Append a `Setting` to the relevant section list; encode and decode update together. Sentinels (`M`, `?`, `X`) keep older decoders forward-compatible. |
| 235 | +- **Adding an engine.** Implement an engine-specific `Get...ConnectionStringWithApplicationName`, add it to the dispatcher's switch, and map the engine to a `Source` character. The injection sites (file load, hosted, multi-database) are already engine-agnostic. |
| 236 | + |
| 237 | + |
| 238 | +## References |
| 239 | + |
| 240 | +- Tracking issue: [#3216](https://github.com/Azure/data-api-builder/issues/3216) |
| 241 | +- Key types: `ApplicationNameTelemetry` (`src/Config/Telemetry/`), `RuntimeConfigLoader` / `FileSystemRuntimeConfigLoader` (`src/Config/`), `RuntimeConfigProvider` (`src/Core/Configurations/`), `LogBuffer` (`src/Config/`), `AppNameOptions` (`src/Cli/Commands/`). |
0 commit comments