Skip to content

feat: log prepared HTTP request URLs through observers - #997

Draft
dpiet-oai wants to merge 3 commits into
mainfrom
dp/http-request-url-logging
Draft

feat: log prepared HTTP request URLs through observers#997
dpiet-oai wants to merge 3 commits into
mainfrom
dp/http-request-url-logging

Conversation

@dpiet-oai

@dpiet-oai dpiet-oai commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Issue #886 reports that HttpRequest.url() logs a path space as + even though OkHttp sends %20. The issue proposes changing HttpRequest.url(). This PR fixes the log by reading the default transport's prepared URL; it does not change HttpRequest.url() or resolve that separate public-method request. PR #887 covers the narrower path-space change.

RequestObserver supplies the prepared method and URL once per attempt, before dispatch, for sync and async calls. The SDK's retry, authentication, lifecycle, workload identity, and X.509 wrappers forward it. Core gains no OkHttp dependency. The reported URL describes the initial native request; later interceptors and redirects may change it.

Differences covered

For new path segments, the complete measured character-level difference set is space (+ in core, %20 in OkHttp) and these 15 characters (percent-escaped in core, literal in OkHttp): !, $, &, ', (, ), +, ,, :, ;, =, @, [, ], ~. For new query names and values, only space differs (+ versus %20); both encode a literal + as %2B.

The fixture also covers exact ./.. segments, existing base queries and fragments, empty paths, scheme/host/IDN/IP/port normalization, authority separators and backslashes, raw base spaces/Unicode/controls and other unsafe characters, userinfo, and invalid URL rejection. It includes matching cases such as added Unicode, raw %, slash/backslash segments, and repeated query values. Core appends to the base string using URLEncoder; OkHttp parses and builds a native URL. These differences can change the request target, not just its spelling.

Before and after

For example:

val request = HttpRequest.builder()
    .method(HttpMethod.GET)
    .baseUrl("https://example.com/v1")
    .addPathSegment("a b")
    .build()
// request.url() remains https://example.com/v1/a+b.

With LoggingHttpClient at INFO around the default OkHttp transport, the examples below show the request log's URL and the initial HTTP path/query target. Targets exclude fragments. Sending behavior does not change.

Before this PR

Input (https://example.com unless shown) Logged URL Sent target
Base /v1, segment a b https://example.com/v1/a+b /v1/a%20b
Base /v1, segment a+b https://example.com/v1/a%2Bb /v1/a+b
Base /v1, query a b=c d https://example.com/v1?a+b=c+d /v1?a%20b=c%20d
Base /v1, segments a, .., b https://example.com/v1/a/../b /v1/b
Base /v1?x=1, segment users https://example.com/v1?x=1/users /v1/users?x=1
Base /v1#section, segment users https://example.com/v1#section/users /v1/users
Bare https://example.com https://example.com /
Base HTTPS://EXAMPLE.COM:443/v1 HTTPS://EXAMPLE.COM:443/v1 /v1
Base https://example.com:65536/v1 URL line, then error No request: OkHttp rejects it

After this PR

Same input Logged URL Sent target
Base /v1, segment a b https://example.com/v1/a%20b /v1/a%20b
Base /v1, segment a+b https://example.com/v1/a+b /v1/a+b
Base /v1, query a b=c d https://example.com/v1?a%20b=c%20d /v1?a%20b=c%20d
Base /v1, segments a, .., b https://example.com/v1/b /v1/b
Base /v1?x=1, segment users https://example.com/v1/users?x=1 /v1/users?x=1
Base /v1#section, segment users https://example.com/v1/users#section /v1/users
Bare https://example.com https://example.com/ /
Base HTTPS://EXAMPLE.COM:443/v1 https://example.com/v1 /v1
Base https://example.com:65536/v1 Error only; no request URL line No request

The full prepared URL can contain a fragment; the sent target cannot. HttpRequest.url() still returns the core-rendered form shown in the first table.

Custom HTTP clients

Existing custom HttpClient implementations continue to compile and send through their original methods. Without observer support, logging now shows --> GET <URL unavailable> instead of a potentially wrong URL, plus this guidance once per logging client: Implement and forward RequestObserver in execute and executeAsync to enable URL logging. The same applies to an old wrapper around OkHttp: its sent target is unchanged, but the wrapper's default observer overload reports no URL.

To restore URL logging, a wrapper forwards the observer in both new overloads (shown here inside an existing HttpClient implementation):

override fun execute(request: HttpRequest, options: RequestOptions, observer: RequestObserver): HttpResponse =
    delegate.execute(request, options, observer)

override fun executeAsync(
    request: HttpRequest, options: RequestOptions, observer: RequestObserver,
): CompletableFuture<HttpResponse> = delegate.executeAsync(request, options, observer)

A custom transport instead reports its own prepared method and URL with observer.onRequestStart(method, url) before dispatch, on each attempt. An override that drops the callback cannot provide a request log line. Async callbacks may run on another thread. SDK transports ignore observer runtime exceptions so observation cannot prevent dispatch.

Verification and release

  • The 147-case offline matrix passes: 117 accepted prepared URLs match the log; 30 rejected URLs produce no dispatch or request line. A WireMock test separately compares a real request target with its log. Sync/async, retries, legacy clients, and X.509 paths have focused coverage. Existing HttpRequest.url() tests retain and pass their current expectations.
  • Core and OkHttp test suites and Kotlin lint pass offline with SKIP_MOCK_TESTS=true; the external OpenAPI mock server tests were skipped. The generated-code budget passes at 2,157 / 3,000 lines, unchanged from baseline.
  • This adds functionality with a logging change for legacy custom clients. Request a minor version bump (feat:). Please include security-focused review of URL/path and transport handling; tests use synthetic inputs. Existing opt-in DEBUG body logs can contain sensitive data.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Castiron custom code

✅ No new custom-code files detected.

70 mixed files remain; 0 existing customizations changed.

Compared 41bdd89bce7deae6187e5414. Generated baselines verified.

70 existing customizations unchanged
  • openai-java-core/src/main/kotlin/com/openai/models/audio/AudioResponseFormat.kt
  • openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionCreateParams.kt
  • openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionMessageFunctionToolCall.kt
  • openai-java-core/src/main/kotlin/com/openai/models/chat/completions/ChatCompletionToolMessageParam.kt
  • openai-java-core/src/main/kotlin/com/openai/models/embeddings/Embedding.kt
  • openai-java-core/src/main/kotlin/com/openai/models/embeddings/EmbeddingCreateParams.kt
  • openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseCreateParams.kt
  • openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseFunctionToolCall.kt
  • openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseFunctionWebSearch.kt
  • openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseInputItem.kt
  • openai-java-core/src/main/kotlin/com/openai/models/responses/ResponseTextConfig.kt
  • openai-java-core/src/main/kotlin/com/openai/models/videos/Video.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/BetaServiceAsync.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/BetaServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/ResponseServiceAsync.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/ResponseServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/WebhookServiceAsync.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/WebhookServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/audio/TranscriptionServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/beta/agents/SessionServiceAsync.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/beta/agents/SessionServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/chat/ChatCompletionServiceAsync.kt
  • openai-java-core/src/main/kotlin/com/openai/services/async/finetuning/checkpoints/PermissionServiceAsyncImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/BetaService.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/BetaServiceImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/ResponseService.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/ResponseServiceImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/WebhookService.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/WebhookServiceImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/audio/TranscriptionServiceImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/beta/agents/SessionService.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/beta/agents/SessionServiceImpl.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/chat/ChatCompletionService.kt
  • openai-java-core/src/main/kotlin/com/openai/services/blocking/finetuning/checkpoints/PermissionServiceImpl.kt
  • openai-java-core/src/test/kotlin/com/openai/models/beta/responses/BetaResponseFunctionWebSearchTest.kt
  • openai-java-core/src/test/kotlin/com/openai/models/beta/responses/BetaResponsesServerEventTest.kt
  • openai-java-core/src/test/kotlin/com/openai/models/live/ClientEventTest.kt
  • openai-java-core/src/test/kotlin/com/openai/models/live/ServerEventTest.kt
  • openai-java-core/src/test/kotlin/com/openai/models/live/SessionClosedEventTest.kt
  • openai-java-core/src/test/kotlin/com/openai/models/live/SessionStartEventTest.kt

30 more in the full report.

A changed generated baseline means this report cannot reliably identify which handwritten lines changed.

Inspect the custom-code diff

Download the exact patch produced by this run (requires repository access):

gh run download 34629845008 --repo openai/openai-java \
  --name castiron-custom-code-34629845008-1 --dir /tmp/castiron-custom-code-34629845008-1
git apply --stat /tmp/castiron-custom-code-34629845008-1/custom-code.patch
cat /tmp/castiron-custom-code-34629845008-1/custom-code.patch

Or reproduce it from an SDK checkout containing the vendored reporter:

git fetch --no-tags origin 41bdd89bce7d88c14ce09f891b11bb7eb61ba534 eae6187e5414474dfe3c745ab426eee73467a345
python3 scripts/castiron/custom_code_report.py report \
  --base 41bdd89bce7d88c14ce09f891b11bb7eb61ba534 \
  --head eae6187e5414474dfe3c745ab426eee73467a345 --fetch --require-head-hash --public \
  --out /tmp/castiron-custom-code-eae6187e5414
cat /tmp/castiron-custom-code-eae6187e5414/custom-code.patch

This is the current full custom patch for mixed files, not an attribution of only the handwritten lines changed by this PR.

Full report and patch

@jbeckwith-oai jbeckwith-oai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one confirmed behavioral compatibility regression: recompiling an existing Kotlin HttpClient by delegate wrapper can bypass its original execution overrides. Details and validation are in the inline comment.

Review conclusions:

  • API contract: HttpRequest.url() and default OkHttp wire construction remain unchanged, but the delegated-wrapper regression must be fixed. Legacy custom clients also intentionally lose URL logging in favor of <URL unavailable>; that remains an explicit compatibility tradeoff.
  • Generator ownership: None of the 16 changed files belongs to the verified generated snapshot 518b0539b6c7380ca65ceaedae379187c4545226. Generation metadata is unchanged. The custom-code budget passes at 2,157 / 3,000, unchanged from base.
  • Support matrix: No JVM/compiler floors or runtime dependencies change. Java 8 remains supported; exact-head CI passes its Java 8/25 runtime checks and build/Jackson/API checks. Those checks do not cover this delegated-wrapper behavior.
  • API design: Reading the prepared URL from the transport is the right boundary and keeps core independent of OkHttp. Observation should be an explicit opt-in capability that preserves existing wrapper dispatch. A separate optional interface is one possible design; please agree on that API shape before expanding the implementation.

Validation: two independent read-only reviewers identified the same blocker; a separate offline counting-wrapper fixture confirmed it on Java 8 and 25, including against the complete Gradle-built PR JAR. Focused core HTTP and OkHttp tests passed locally (509 passed, 1 skipped), along with Kotlin lint, git diff --check, and the custom-code budget check. Security-focused inspection found no additional supported blocker.

Reviewed head eae6187e5414474dfe3c745ab426eee73467a345 against base 41bdd89bce7d88c14ce09f891b11bb7eb61ba534. No source changes were made.

Comment on lines +73 to +77
httpClient.execute(
loggingRequest,
requestOptions,
loggingObserver(request, observer),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve existing Kotlin delegated wrapper overrides

Calling the new three-argument overload here (and in executeAsync below) silently changes existing consumers using class Wrapper(private val delegate: HttpClient) : HttpClient by delegate with overrides of the original two-argument execute / executeAsync methods. After recompilation, Kotlin generates forwarding methods for the new overloads that call the delegate directly, skipping the wrapper's existing request customization, accounting, or lifecycle logic. This happens even at LogLevel.OFF because this dispatch is unconditional.

The defaults in HttpClient protect ordinary implementations and old binaries, but cannot protect against those newly generated forwarding methods. The existing legacy-client tests use explicit implementations and miss this case. See Kotlin's inheritance delegation semantics.

Confirmed with an offline counting wrapper on both Java 8 and Java 25, including the full Gradle-built PR JAR:

Scenario Sync override calls Async override calls
Before PR 1 1
Old consumer binary running with PR 1 1
Same consumer source recompiled against PR 0 0

Please preserve the original execution path unless a wrapper explicitly opts into observation, and add sync/async delegated-consumer regression coverage with logging off and enabled. A separate optional observation-capable interface is one possible remedy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants