Skip to content

fix(tracer): prevent stale ctx.tracing crash on HTTPS keepalive connections#13232

Merged
nic-6443 merged 10 commits into
apache:masterfrom
janiussyafiq:fix/tracing-https
May 7, 2026
Merged

fix(tracer): prevent stale ctx.tracing crash on HTTPS keepalive connections#13232
nic-6443 merged 10 commits into
apache:masterfrom
janiussyafiq:fix/tracing-https

Conversation

@janiussyafiq

@janiussyafiq janiussyafiq commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Description

When apisix.tracing is enabled, the core tracer instruments every request phase — including ssl_client_hello_phase — by allocating a tracing table from lua-tablepool and storing it in ngx.ctx.tracing.

On HTTPS/HTTP2 keepalive connections, OpenResty creates the SSL-phase ngx.ctx once per TLS connection and uses it as the __index metatable for each subsequent HTTP request's ngx.ctx. This means reading ctx.tracing on a per-request ctx falls through to the SSL-phase ctx's tracing field — all requests on the same connection share the same tracing table.

The bug occurs in the following sequence:

  1. ssl_client_hello_phase calls tracer.start(), which writes ctx.tracing onto the SSL-phase ctx.
  2. The first HTTP request's log phase calls tracer.release(), returning the tracing table to the pool. lua-tablepool zeroes all fields via table.clear(), but ctx.tracing = nil only writes onto the per-request ctx — the SSL-phase ctx's tracing field is untouched (no __newindex propagation).
  3. The second HTTP request inherits the same stale ctx.tracing via __index and crashes at table.insert(tracing.spans, self) in span.new() because spans is nil.

This fix addresses the root cause by severing the metatable inheritance using rawget/rawset:

  • tracer.start(): Uses rawget(ctx, "tracing") instead of ctx.tracing. rawget bypasses __index, so each per-request ctx sees nil and initialises its own fresh tracing table via rawset. In the SSL phase, ctx is the SSL-phase table directly so rawget behaves identically to a plain read — no change in behaviour for that phase.
  • tracer.release(): Uses rawset(ctx, "tracing", nil) to clear the per-request ctx's own field directly, bypassing any metatable.
  • ssl_client_hello_phase in init.lua: tracer.release(ngx_ctx) is now called at all exit points of ssl_client_hello_phase. With rawget in place, the SSL-phase tracing table would otherwise never be picked up or released by any HTTP request phase and would leak for the lifetime of the TLS connection. Making it explicit and SSL-scoped is the correct shape.

One consequence of this fix: the SSL-handshake span is now its own standalone trace rather than being stitched into the HTTP request's trace. Stitching across SSL and HTTP phases requires propagating span context IDs (traceparent), not sharing the live tracing object — this is left as a separate change.

Which issue(s) this PR fixes:

Fixes #13200

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change (t/node/tracer.t, t/plugin/opentelemetry6.t)
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. bug Something isn't working labels Apr 16, 2026
Baoyuantop
Baoyuantop previously approved these changes Apr 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Fixes a crash in APISIX comprehensive tracing on HTTPS keepalive connections by ensuring tracing state in ngx.ctx is re-initialized when the pooled table has been cleared, and adds a regression test to cover consecutive HTTPS requests with tracing enabled.

Changes:

  • Make tracer.start() reinitialize tracing when ctx.tracing.spans is missing (stale/cleared pooled table).
  • Make tracer.release() tolerant of partially-cleared tracing state by guarding span iteration/release.
  • Add a node test that performs consecutive HTTPS requests over a reused connection while tracing is enabled.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apisix/tracer.lua Hardens tracer init/release logic against cleared pooled state in ngx.ctx across HTTPS keepalive requests.
t/node/tracer.t Adds regression coverage for consecutive HTTPS requests with apisix.tracing: true.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apisix/tracer.lua
@hachi029

Copy link
Copy Markdown
Contributor

Does this fix apply to HTTP/2 in cases where the second HTTP request starts before the first one ends, causing the two requests to share the same ctx.tracing?

@janiussyafiq

Copy link
Copy Markdown
Contributor Author

Does this fix apply to HTTP/2 in cases where the second HTTP request starts before the first one ends, causing the two requests to share the same ctx.tracing?

https://github.com/apache/apisix/pull/13232/changes#diff-1b0efc84c955bbecaf03cb73a6b7c72f8cac0b1045c1d069fc01fa390d2c31daR109-R114

i've added the test case here to address your issue. after doing some digging, i found that in http2 requests, ctx.tracing won't be shared across different requests since each request would init a new one. hence could u elaborate more on your issue or if there is any reproduction steps?

@Baoyuantop

Copy link
Copy Markdown
Contributor

The PR is on the right track; please fix the failed CI.

@hachi029

hachi029 commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Does this fix apply to HTTP/2 in cases where the second HTTP request starts before the first one ends, causing the two requests to share the same ctx.tracing?

https://github.com/apache/apisix/pull/13232/changes#diff-1b0efc84c955bbecaf03cb73a6b7c72f8cac0b1045c1d069fc01fa390d2c31daR109-R114

i've added the test case here to address your issue. after doing some digging, i found that in http2 requests, ctx.tracing won't be shared across different requests since each request would init a new one. hence could u elaborate more on your issue or if there is any reproduction steps?

Does this fix apply to HTTP/2 in cases where the second HTTP request starts before the first one ends, causing the two requests to share the same ctx.tracing?

https://github.com/apache/apisix/pull/13232/changes#diff-1b0efc84c955bbecaf03cb73a6b7c72f8cac0b1045c1d069fc01fa390d2c31daR109-R114

i've added the test case here to address your issue. after doing some digging, i found that in http2 requests, ctx.tracing won't be shared across different requests since each request would init a new one. hence could u elaborate more on your issue or if there is any reproduction steps?

We recently encountered an similar issue in our online environment involving ngx.ctx during the SSL phase and request processing phases. Here is an example to demonstrate the problem:

nginx.conf:

   ssl_certificate_by_lua_block {
        ngx.ctx.tracing={}
        ngx.log(ngx.WARN, "id: ", tostring(ngx.ctx.tracing))
    }
    location /slow {
        content_by_lua_block {
            ngx.log(ngx.WARN, "id(slow): ", tostring(ngx.ctx.tracing))
            table.insert(ngx.ctx.tracing, "slow_1")    
            ngx.sleep(1)
            table.insert(ngx.ctx.tracing, "slow_2")    
            ngx.say("slow")
        }
    }
    location /fast {
        content_by_lua_block {
            ngx.log(ngx.WARN, "id(fast): ", tostring(ngx.ctx.tracing))
            table.insert(ngx.ctx.tracing, "fast_1")    
            ngx.say("fast")
        }
    }
    log_by_lua_block {
        ngx.log(ngx.WARN, "data: ", table.concat(ngx.ctx.tracing, ", "), ", id:", tostring(ngx.ctx.tracing))
        ngx.ctx.tracing=nil
        ngx.log(ngx.WARN, "nil?(after set to nil): ", ngx.ctx.tracing==nil)
        ngx.log(ngx.WARN, "data(after set to nil): ", table.concat(ngx.ctx.tracing, ", "))
    }

Run the command bellow to process two requests concurrently within the same connection:

curl -Z -k --http2 https://127.0.0.1:8443/slow  https://127.0.0.1:8443/fast

Error Log Output:(Prefix timestamps and metadata removed)

ssl_certificate_by_lua(nginx.conf:119):3: id: table: 0x7ff77b5a1060, context: ssl_certificate_by_lua*, client: 127.0.0.1, server: 0.0.0.0:8443
content_by_lua(nginx.conf:131):2: id(slow): table: 0x7ff77b5a1060, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
content_by_lua(nginx.conf:141):2: id(fast): table: 0x7ff77b5a1060, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):2: data: slow_1, fast_1, id:table: 0x7ff77b5a1060 while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):4: nil?(after set to nil): false while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):5: data(after set to nil): slow_1, fast_1 while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):2: data: slow_1, fast_1, slow_2, id:table: 0x7ff77b5a1060 while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):4: nil?(after set to nil): false while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):5: data(after set to nil): slow_1, fast_1, slow_2 while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"

Key Observations from the Logs:

  • ngx.ctx.tracing is shared across different requests on the same connection, leading to data interference between requests.
  • Setting ngx.ctx.tracing = nil does not achieve the expected result; the value persists.

Based on the implementation relate to ngx.ctx get_ctx_table, the ngx.ctx accessed during the SSL phase acts as the metatable for the ngx.ctx accessed during the request processing phase.

In the example above:

  • The value retrieved from ngx.ctx.tracing is always the one initialized during the SSL phase.
  • Setting ngx.ctx.tracing to nil in the request phase does nothing because the request-level ngx.ctx table does not actually contain the tracing key itself. When access it again, Lua continues to look up the key along the metatable and finding the value stored in the SSL-phase ngx.ctx.

In this specific issue, the PR fixes the crash, the tracing data still interference across concurrent requests.

@janiussyafiq

Copy link
Copy Markdown
Contributor Author

We recently encountered an similar issue in our online environment involving ngx.ctx during the SSL phase and request processing phases. Here is an example to demonstrate the problem:

nginx.conf:

   ssl_certificate_by_lua_block {
        ngx.ctx.tracing={}
        ngx.log(ngx.WARN, "id: ", tostring(ngx.ctx.tracing))
    }
    location /slow {
        content_by_lua_block {
            ngx.log(ngx.WARN, "id(slow): ", tostring(ngx.ctx.tracing))
            table.insert(ngx.ctx.tracing, "slow_1")    
            ngx.sleep(1)
            table.insert(ngx.ctx.tracing, "slow_2")    
            ngx.say("slow")
        }
    }
    location /fast {
        content_by_lua_block {
            ngx.log(ngx.WARN, "id(fast): ", tostring(ngx.ctx.tracing))
            table.insert(ngx.ctx.tracing, "fast_1")    
            ngx.say("fast")
        }
    }
    log_by_lua_block {
        ngx.log(ngx.WARN, "data: ", table.concat(ngx.ctx.tracing, ", "), ", id:", tostring(ngx.ctx.tracing))
        ngx.ctx.tracing=nil
        ngx.log(ngx.WARN, "nil?(after set to nil): ", ngx.ctx.tracing==nil)
        ngx.log(ngx.WARN, "data(after set to nil): ", table.concat(ngx.ctx.tracing, ", "))
    }

Run the command bellow to process two requests concurrently within the same connection:

curl -Z -k --http2 https://127.0.0.1:8443/slow  https://127.0.0.1:8443/fast

Error Log Output:(Prefix timestamps and metadata removed)

ssl_certificate_by_lua(nginx.conf:119):3: id: table: 0x7ff77b5a1060, context: ssl_certificate_by_lua*, client: 127.0.0.1, server: 0.0.0.0:8443
content_by_lua(nginx.conf:131):2: id(slow): table: 0x7ff77b5a1060, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
content_by_lua(nginx.conf:141):2: id(fast): table: 0x7ff77b5a1060, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):2: data: slow_1, fast_1, id:table: 0x7ff77b5a1060 while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):4: nil?(after set to nil): false while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):5: data(after set to nil): slow_1, fast_1 while logging request, client: 127.0.0.1, server: localhost, request: "GET /fast HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):2: data: slow_1, fast_1, slow_2, id:table: 0x7ff77b5a1060 while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):4: nil?(after set to nil): false while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"
log_by_lua(nginx.conf:123):5: data(after set to nil): slow_1, fast_1, slow_2 while logging request, client: 127.0.0.1, server: localhost, request: "GET /slow HTTP/2.0", host: "127.0.0.1:8443"

Key Observations from the Logs:

  • ngx.ctx.tracing is shared across different requests on the same connection, leading to data interference between requests.
  • Setting ngx.ctx.tracing = nil does not achieve the expected result; the value persists.

Based on the implementation relate to ngx.ctx get_ctx_table, the ngx.ctx accessed during the SSL phase acts as the metatable for the ngx.ctx accessed during the request processing phase.

In the example above:

  • The value retrieved from ngx.ctx.tracing is always the one initialized during the SSL phase.
  • Setting ngx.ctx.tracing to nil in the request phase does nothing because the request-level ngx.ctx table does not actually contain the tracing key itself. When access it again, Lua continues to look up the key along the metatable and finding the value stored in the SSL-phase ngx.ctx.

In this specific issue, the PR fixes the crash, the tracing data still interference across concurrent requests.

WDYT @Baoyuantop @membphis @nic-6443 ? I can confirm that this PR solves the crash issue, but I am not sure how to write test case to confirm no data interference between both requests. One idea is to check span using opentelemetry but ig we can do it in separate PR? i'm working on a solution right now just want to have ur guys opinion.

@shreemaan-abhishek

Copy link
Copy Markdown
Contributor

ctx.tracing = nil in release() is a no-op on a request-phase ctx

@hachi029 already demonstrated this empirically. The per-request ngx.ctx in an HTTP phase is created as a fresh empty table whose __index metatable points at the connection-scoped SSL-phase ctx. On the same TLS connection this means three distinct Lua tables are in play:

  • The SSL-phase ctx table: lives on the TLS connection, populated once during the handshake, shared with every HTTP request on that connection via the metatable link below.
  • Request 1's per-request ctx table: a fresh empty table created on first ngx.ctx access in request 1, whose metatable is the SSL-phase ctx table.
  • Request 2's per-request ctx table: another fresh empty table created on first ngx.ctx access in request 2, whose metatable is also the SSL-phase ctx table.

tracer.start() runs in ssl_client_hello_phase (apisix/init.lua:209), where ctx is the SSL-phase ctx table directly, so it writes tracing into the SSL-phase ctx table. In every later HTTP phase, reading ctx.tracing on a per-request ctx finds no own field called tracing and falls through __index to the SSL-phase ctx table's tracing field. Writing ctx.tracing = nil on a per-request ctx only affects that per-request ctx; the SSL-phase ctx table has no __newindex metamethod, so its tracing field is untouched and the next read on any per-request ctx still returns the same tracing table.

This matches @hachi029's repro: the logged table address 0x7ff77b5a1060 is identical in the SSL phase, both concurrent HTTP/2 streams, and the log phase, and ngx.ctx.tracing = nil followed by a re-read returns the same table.

So line 88 (ctx.tracing = nil) is dead code under the metatable inheritance that OpenResty actually uses. The crash is prevented purely by the not tracing.spans guard on line 42, and it only happens to work because tablepool.fetch returns the just-released tracing table back (LIFO), effectively re-fusing it onto the SSL-phase ctx's tracing field.

Proposal: sever the inheritance with rawget / rawset

Both issues disappear if tracer.start and tracer.release operate on the per-request ctx directly, bypassing the metatable:

function _M.start(ctx, name, kind)
    ...
    local tracing = rawget(ctx, "tracing")
    if not tracing then
        tracing = tablepool.fetch("tracing", 0, 8)
        tracing.spans = tablepool.fetch("tracing_spans", 20, 0)
        rawset(ctx, "tracing", tracing)
    end
    ...
end

function _M.release(ctx)
    local tracing = rawget(ctx, "tracing")
    if not tracing then
        return
    end
    for _, sp in ipairs(tracing.spans) do
        sp:release()
    end
    tablepool.release("tracing_spans", tracing.spans)
    tablepool.release("tracing", tracing)
    rawset(ctx, "tracing", nil)
end

Why this works:

  • In the SSL phase, ctx is the SSL-phase ctx table directly; rawget on it is identical to the regular field read. The SSL-handshake span continues to be recorded on the SSL-phase ctx table's tracing field as before.
  • In every HTTP phase, rawget(ctx, "tracing") returns nil on first access because the per-request ctx has no own tracing key and rawget does not follow __index. Each request initialises its own fresh tracing table on its own per-request ctx. No cross-stream sharing, no stale-pointer crash, no double-release.
  • The not tracing.spans defensive check is no longer needed rawget guarantees each phase only ever sees its own tracing table, which is either nil or fully initialised.

CAVEAT: Release the SSL-phase tracing table explicitly

With rawget in place, the tracing table on the SSL-phase ctx (set during ssl_client_hello_phase) is no longer picked up or released by any HTTP request phase, so it would leak for the lifetime of the TLS connection. It should be released at the end of ssl_client_hello_phase in apisix/init.lua, immediately after span:finish(ngx_ctx):

span:finish(ngx_ctx)
tracer.release(ngx_ctx)

This is, in effect, what's happening implicitly today the SSL-phase tracing table is being released by request 1's log phase, which is exactly the coupling that causes #13200. Making it explicit and SSL-scoped is the correct shape.

One consequence: the SSL-handshake span becomes its own standalone trace rather than being stitched into the HTTP request's trace. Stitching across SSL and HTTP requires propagating only the span context (traceparent IDs), not the live tracing object a separate, larger change.

TEST 4 currently only asserts the response body, which isn't sensitive to either span mixing or log-phase failures. A useful addition would be to assert, at the end of each request, that ngx.ctx.tracing.spans contains only the spans belonging to that request, i.e. no cross-stream contamination.

nic-6443
nic-6443 previously approved these changes Apr 23, 2026
@nic-6443

Copy link
Copy Markdown
Member

I have carefully read the problem analysis and fix suggestions provided by @shreemaan-abhishek, and I agree with his views. It is the correct approach to separate the tracers for SSL and HTTP, because the same SSL connection can be shared by multiple HTTP requests, and forcibly linking them together will lead to data disorder issues.

@janiussyafiq

janiussyafiq commented Apr 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough and detailed explanation @shreemaan-abhishek, and I totally agree with your view and have already implemented the fix based on your suggestion.

The changes made:

  • tracer.start() now uses rawget(ctx, "tracing") instead of ctx.tracing, bypassing the __index metatable chain so each per-request ctx gets its own fresh tracing table
  • tracer.release() now uses rawset(ctx, "tracing", nil) to clear the per-request ctx's own field directly
  • tracer.release(ngx_ctx) is now explicitly called at all exit points of ssl_client_hello_phase in init.lua to prevent the SSL-phase tracing table from leaking for the lifetime of the TLS connection

As you mentioned, one consequence of this fix is that the SSL-handshake span is now its own standalone trace rather than being stitched into the HTTP request's trace. This also means ssl_client_hello_phase no longer appears in the OTel exported spans — since the SSL-phase tracing table is released at the end of ssl_client_hello_phase, the OTel plugin (which runs in the HTTP log phase) never has access to it. As a result, the existing ssl_client_hello_phase child span assertion in opentelemetry6.t TEST 6 has been removed to reflect this new behaviour.
https://github.com/apache/apisix/pull/13232/changes#diff-9e1d47dc1723f3a0ba7fc191e686f69c23200b98766520a15878cc862a7e7533L221-L227
cc @nic-6443

For the tests, I have added:

  • t/node/tracer.t: consecutive HTTPS keepalive
  • t/plugin/opentelemetry6.t: concurrent HTTP/2 span isolation test using a new verify_isolated_traces helper in t/lib/test_otel.lua that verifies each stream produces its own separate trace with no duplicate spans (cross-stream contamination)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread t/node/tracer.t
Comment thread t/plugin/opentelemetry6.t Outdated
Comment thread t/lib/test_otel.lua
@nic-6443
nic-6443 merged commit c808156 into apache:master May 7, 2026
20 of 21 checks passed
@janiussyafiq
janiussyafiq deleted the fix/tracing-https branch May 7, 2026 15:22
wistefan pushed a commit to wistefan/apisix that referenced this pull request Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: apisix 3.16.0 comprehensive tracing breaks with HTTPS keepalive connections

6 participants