feat(csharp): add opt-in appInfo client option appended to User-Agent - #17303
Conversation
Add a new SDK generator config `allow-user-agent-app-info` (kebab-case; settings getter `allowUserAgentAppInfo`, default false). When enabled, the generated client exposes an optional `AppInfo` client option (`Name`, `Version?`, `Comment?`) on `ClientOptions` whose sanitized product token is appended to whatever `User-Agent` the SDK would otherwise send, producing e.g. `com.acme.sdk/1.4.0 (linux; x86_64) dotnet/8.0.4 partner-app/3.1.0 (+https://partner.example)` per RFC 9110 §10.1.5. The appendix is applied to both User-Agent branches, gated on the flag: the structured `BuildUserAgent()` value (`include-platform-headers`) and the non-structured `buildUserAgentHeaderEntry` value (which also covers the configured `user-agent` template value and the `user-agent-name-from-package` fallback). Caller-supplied values are trimmed before the blank check and before encoding, so blank name/version/comment are dropped rather than encoded into whitespace tokens; then name/version are percent-encoded to RFC 7230 `tchar` and comment delimiters `(`, `)`, `\` and control chars (incl. CR/LF) are escaped, so untrusted values cannot inject header content. The `AppInfo` record, the `AppInfo` ClientOptions property, and the emitted `AppendAppInfoToUserAgent` helper are only generated when the flag is on (and a User-Agent is actually written), so default-off output is byte-identical and the shared always-shipped core-utilities are never modified. Works in both unified and non-unified client-options modes. Still overridable by an explicit `User-Agent` header and suppressed by `omit-fern-headers`. No IR change. The emitted helper uses only netstandard2.0/net462-safe APIs (StringBuilder + UTF-8 bytes, no regex), so no new package reference is required; the new fixture compiles clean for net462, net8.0, and netstandard2.0. Co-Authored-By: Claude <noreply@anthropic.com>
| `static void AppendPercentEncoded(${STRING_BUILDER} builder, char ch)`, | ||
| "{", | ||
| ` foreach (var b in ${ENCODING}.UTF8.GetBytes(new[] { ch }))`, | ||
| " {", | ||
| " builder.Append('%').Append(b.ToString(\"X2\"));", | ||
| " }", | ||
| "}", |
There was a problem hiding this comment.
🟡 App names containing emoji or other rare characters are garbled in the User-Agent header
Each character of the caller-supplied app name and version is converted to bytes one at a time (Encoding.UTF8.GetBytes(new[] { ch }) at generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:125) rather than in pairs, so any character represented by two code units is turned into replacement placeholders.
Impact: An app name or version containing emoji or other rare characters shows up as unreadable placeholder text in the User-Agent that servers and analytics see.
Per-char UTF-8 encoding destroys surrogate pairs
EncodeToken (generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:83-103) iterates foreach (var ch in value) and, for every character outside the RFC 7230 tchar set, calls AppendPercentEncoded(builder, ch). For a non-BMP scalar (e.g. U+1F680), value holds two UTF-16 surrogate code units; Encoding.UTF8.GetBytes on a single lone surrogate emits the replacement-character bytes EF BF BD, so the token becomes %EF%BF%BD%EF%BF%BD instead of the real UTF-8 bytes. EncodeComment (generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts:107-122) also routes escaped chars through the same helper, though it only escapes ASCII delimiters/control chars there. A fix is to encode the whole string once (or detect char.IsHighSurrogate and encode the pair together) and percent-encode the resulting byte sequence for the characters that need encoding.
Prompt for agents
In generators/csharp/sdk/src/root-client/buildAppInfoUserAgent.ts, the emitted C# helper percent-encodes one UTF-16 char at a time via AppendPercentEncoded(builder, ch), which calls Encoding.UTF8.GetBytes(new[] { ch }). For characters outside the BMP the string contains a surrogate pair, and encoding each half separately yields the U+FFFD replacement bytes (%EF%BF%BD) twice, silently corrupting the app name/version in the User-Agent. Consider changing the emitted helper so that encoding operates on complete scalars: e.g. in EncodeToken/EncodeComment iterate by index, detect char.IsHighSurrogate(ch) with a following low surrogate and encode both chars together (Encoding.UTF8.GetBytes(value, i, 2) or new[] { high, low }), or pre-encode the whole string to UTF-8 bytes and decide per byte whether it needs percent-encoding. Keep to netstandard2.0/net462-safe APIs (no regex, no Rune) and update the unit tests in generators/csharp/sdk/src/root-client/__test__/buildAppInfoUserAgent.test.ts accordingly.
Was this helpful? React with 👍 or 👎 to provide feedback.
SDK Generation Benchmark ResultsComparing PR branch against median of 5 nightly run(s) on Full benchmark table (click to expand)
main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via |
…tformHeaders
Adds a new imdb fixture variant `include-platform-headers-app-info` that
sets both `includePlatformHeaders: true` and `allowUserAgentAppInfo: true`,
proving the appInfo User-Agent appender composes with the structured
platform User-Agent branch (getPlatformUserAgent), not just the default
`{package}/{version}` literal. Mirrors the Java (#17301) and C# (#17303)
combined fixtures.
Co-Authored-By: Claude <noreply@anthropic.com>
…atformHeaders Adds a new `include-platform-headers-app-info` variant of the `examples` fixture that sets both `includePlatformHeaders: true` and `allowUserAgentAppInfo: true`. This proves the appInfo User-Agent token composes with the structured/platform User-Agent branch (`RawClient.user_agent(...)`) via `append_app_info(...)`, mirroring the Java (#17301) and C# (#17303) fixtures. Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Ports the opt-in
appInfoUser-Agent feature (TS reference PR #17290) to the C# SDK generator. Adds a new SDK generator configallow-user-agent-app-info(kebab-case; settings getterallowUserAgentAppInfo, defaultfalse). When enabled, the generated client exposes an optionalAppInfoclient option (Name,Version?,Comment?) onClientOptionswhose sanitized product token is appended to whateverUser-Agentthe SDK would otherwise send, per RFC 9110 §10.1.5. Independent ofinclude-platform-headers. No IR change.Gated / default-off (byte-identical)
Every appInfo artifact is emitted only when the flag is on:
AppInforecord (newAppInfoGenerator, gated inSdkGeneratorCli),AppInfo?property onClientOptions(+ wired intoClone()/ copy-constructor),AppendAppInfoToUserAgenthelper (emitted only when the flag is on and a User-Agent is actually written),withAppInfo(...)wrapping of the User-Agent value.Flag off ⇒ literal empty diff for every pre-existing fixture. The shared always-shipped core-utilities and
BuildUserAgent()are never modified.Where it's emitted / how it composes
The appendix wraps both User-Agent branches (gated):
BuildUserAgent()value (include-platform-headerson), andbuildUserAgentHeaderEntryvalue, which also covers the configureduser-agenttemplate value and theuser-agent-name-from-packagefallback.AppInfolives onClientOptions, so it works in bothunified-client-optionsand non-unified modes — the wrapping readsclientOptions.AppInfo, andclientOptionsis already initialized (non-null) at the point the platform-headers dictionary is written. Runtime-version path is unaffected:BuildUserAgent()still resolves OS/arch/runtime at runtime; the appInfo token is appended around its result.Before / after User-Agent (new fixture)
include-platform-headers):{ "User-Agent", BuildUserAgent() }allow-user-agent-app-info):{ "User-Agent", AppendAppInfoToUserAgent(BuildUserAgent(), clientOptions.AppInfo) }Example rendered value (runtime-verified):
com.acme.sdk/1.4.0 (linux; x86_64) dotnet/8.0.4 partner-app/3.1.0 (+https://partner.example)Sanitization (trim-before-encode; no whitespace-junk-token bug)
Name/Version/Commentare trimmed before the blank check and before encoding, so blank values are dropped rather than encoded into whitespace tokens. ThenName/Versionare percent-encoded to RFC 7230tchar(from UTF-8 bytes) andCommenthas(,),\and control chars (incl. CR/LF, 0x7F) escaped. The helper uses only netstandard2.0/net462-safe APIs (StringBuilder+ UTF-8 bytes, no regex), so no new package reference is required.Sanitization cases covered (TS unit tests in
buildAppInfoUserAgent.test.ts, plus the exact emitted helper compiled+run for runtime verification — all PASS):Name→ User-Agent unchanged (incl."\t\n ")Version/ blankCommentomitted;" app "/" 1.0 "trimmed toapp/1.0ev%0D%0Ail; space in name →a%20ba) evil (→(a%29 evil %28); backslasha\b→(a%5Cb); CRLF in commenta\r\nb→(a%0D%0Ab)Empty-diff confirmation
Regenerated the
imdbC# fixtures. Only tracked change among pre-existing fixtures was.fern/metadata.jsonchurn (reverted). All other pre-existing fixtures are byte-identical; only the newimdb/allow-user-agent-app-infofixture is added.TFMs compiled clean
dotnet buildof the regenerated fixture succeeded for net462, net8.0, and netstandard2.0 (also net9.0) — 0 errors (the pre-existingCS1998warning inRawClient.csis unrelated). net462 compiled locally via the .NET 9 SDK's NuGet reference assemblies.Changelog
generators/csharp/sdk/changes/unreleased/allow-user-agent-app-info.yml(type: feat).versions.ymluntouched.Checks
pnpm compile+pnpm seed:buildclean.@fern-api/fern-csharp-sdkunit tests: 86 passed (incl. 11 new).npx biome checkclean on all changed.ts. (Note:pnpm lint:eslintfails to even start locally due to a pre-existing ESLint 9 / eslintrc ajv toolchain error unrelated to this change.)Generated with Claude Code