<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Xitong Shan</title>
    <description>The latest articles on DEV Community by Xitong Shan (@sxt12356).</description>
    <link>https://dev.to/sxt12356</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089872%2F861a99a5-d32e-4ad5-89c1-fcc2b19fd1ce.png</url>
      <title>DEV Community: Xitong Shan</title>
      <link>https://dev.to/sxt12356</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sxt12356"/>
    <language>en</language>
    <item>
      <title>A Redis Lua Rate Limiter for FastAPI That Returns Useful 429 Headers</title>
      <dc:creator>Xitong Shan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 09:25:39 +0000</pubDate>
      <link>https://dev.to/sxt12356/a-redis-lua-rate-limiter-for-fastapi-that-returns-useful-429-headers-27j1</link>
      <guid>https://dev.to/sxt12356/a-redis-lua-rate-limiter-for-fastapi-that-returns-useful-429-headers-27j1</guid>
      <description>&lt;p&gt;Rate limiting is easy to describe and surprisingly easy to implement incorrectly. A request counter must change atomically, the counter needs a reliable expiry, different endpoints need different identities, and blocked clients need enough information to back off.&lt;/p&gt;

&lt;p&gt;This article explains the small Redis-backed limiter in my public FastAPI project, &lt;a href="https://github.com/sxt12356/mini-agent" rel="noopener noreferrer"&gt;&lt;code&gt;mini-agent&lt;/code&gt;&lt;/a&gt;. It protects an authenticated AI-agent chat endpoint and a login endpoint. The design uses one Lua script for the counter and expiry, hashes identities before placing them in Redis keys, and returns &lt;code&gt;429 Too Many Requests&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt; and rate-limit headers.&lt;/p&gt;

&lt;p&gt;The examples are pinned to &lt;a href="https://github.com/sxt12356/mini-agent/tree/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640" rel="noopener noreferrer"&gt;this public commit&lt;/a&gt;, not an unspecified future version of the repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the result before the storage details
&lt;/h2&gt;

&lt;p&gt;The rest of the application should not need to understand Redis return values. The limiter translates them into a small data object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RateLimitResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scope&lt;/code&gt; distinguishes policies such as &lt;code&gt;chat_per_minute&lt;/code&gt;, &lt;code&gt;chat_per_day&lt;/code&gt;, &lt;code&gt;login_ip_per_minute&lt;/code&gt;, and &lt;code&gt;login_user_ip_per_minute&lt;/code&gt;. Returning the count and remaining allowance makes the decision observable without requiring callers to read the Redis key.&lt;/p&gt;

&lt;p&gt;The full implementation is in &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/mini_agent/core/rate_limit.py" rel="noopener noreferrer"&gt;&lt;code&gt;rate_limit.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep increment, first expiry, and TTL in one Lua script
&lt;/h2&gt;

&lt;p&gt;A naive implementation issues separate &lt;code&gt;INCR&lt;/code&gt; and &lt;code&gt;EXPIRE&lt;/code&gt; commands. If the process fails between them, the counter may be left without an expiry. Concurrent requests can also observe an incomplete update.&lt;/p&gt;

&lt;p&gt;Redis executes a Lua script atomically, so this implementation performs the related operations together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INCR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"EXPIRE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"TTL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first hit creates the counter and starts its expiry. Later hits increment the same key without extending the window. The Python layer calculates the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;count_raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;_FIXED_WINDOW_SCRIPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count_raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl_raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;

&lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;
&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an expiry-backed fixed window anchored by the first hit. It is not a sliding-window algorithm and it is not aligned to wall-clock minute boundaries. That simplicity is acceptable for many APIs, but it permits bursts around the moment one key expires and another starts.&lt;/p&gt;

&lt;p&gt;There is also a recovery detail worth noticing. If &lt;code&gt;TTL&lt;/code&gt; unexpectedly returns a negative value, the code reports the configured window as the retry delay, but it does not repair the missing expiry. A hardened version should either restore the expiry inside the Lua script or fail closed and emit an operational alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build scoped keys without storing raw identities
&lt;/h2&gt;

&lt;p&gt;The Redis key combines the policy scope, window size, and a SHA-256 digest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_identity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;identity_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hash_identity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rate:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;identity_hash&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents raw user IDs, usernames, and IP addresses from appearing directly in normal key listings. It does not make low-entropy identities secret: a deterministic, unsalted hash of a known IP range can be guessed offline. If the threat model requires stronger pseudonymization, use an HMAC with a rotated server-side key and document how rotations affect active windows.&lt;/p&gt;

&lt;p&gt;Including the scope prevents a login attempt from consuming the chat allowance. Including the window length avoids a collision if the same scope name is mistakenly reused with different durations, although explicit unique scope names remain clearer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply two policies to authenticated chat
&lt;/h2&gt;

&lt;p&gt;The chat endpoint checks both a per-minute and per-day limit against the authenticated &lt;code&gt;user_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;per_minute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;check_limit_or_raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;limiter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_per_minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CHAT_RATE_LIMIT_PER_MINUTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;check_limit_or_raise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;limiter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat_per_day&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CHAT_RATE_LIMIT_PER_DAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastAPI dependency injection makes this run after authentication but before the route body. &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/mini_agent/api/deps.py" rel="noopener noreferrer"&gt;&lt;code&gt;get_rate_limited_current_user&lt;/code&gt;&lt;/a&gt; receives the authenticated user, checks the limits, and saves the successful minute result on &lt;code&gt;request.state&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The chat route then exposes the successful minute allowance in both response headers and its structured response. The daily result is enforced but is not included in a successful response. If clients need both budgets, return or attach both results rather than implying one set of headers describes every active policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate-limit login without helping username enumeration
&lt;/h2&gt;

&lt;p&gt;The login route has a different identity problem because there is no authenticated user yet. The implementation checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;attempts from one client IP; and&lt;/li&gt;
&lt;li&gt;attempts for one &lt;code&gt;username + IP&lt;/code&gt; pair.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both checks happen regardless of whether the username exists. That avoids creating a simple side channel in which nonexistent usernames skip one rate-limit path.&lt;/p&gt;

&lt;p&gt;The code includes an important proxy warning. It currently takes the first address from &lt;code&gt;X-Forwarded-For&lt;/code&gt; when present, otherwise it uses &lt;code&gt;request.client.host&lt;/code&gt;. A public client can forge &lt;code&gt;X-Forwarded-For&lt;/code&gt; unless a trusted reverse proxy strips and rewrites it. Production code must trust forwarding headers only when the direct peer is an approved proxy or when the framework's proxy configuration has already validated the chain.&lt;/p&gt;

&lt;p&gt;This is not a minor deployment detail. If arbitrary clients can choose the identity used by the IP limiter, they can evade one of the login protections.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a blocked response actionable
&lt;/h2&gt;

&lt;p&gt;When a limit is exceeded, the helper raises FastAPI's &lt;code&gt;HTTPException&lt;/code&gt; with status 429:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rate_limit_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-RateLimit-Scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-RateLimit-Limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-RateLimit-Reset&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;raise_rate_limited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rate_limit_headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;请求过于频繁，请 &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; 秒后再试。&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Retry-After&lt;/code&gt; gives an automated client a concrete delay. The additional headers help a UI explain which policy was reached. Here, &lt;code&gt;X-RateLimit-Reset&lt;/code&gt; contains seconds remaining, not an absolute timestamp. Because header conventions differ across APIs, document that meaning or adopt the current standardized RateLimit fields consistently.&lt;/p&gt;

&lt;p&gt;Clients should still add jitter and avoid firing every queued request at the exact expiry boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the behavior through the API boundary
&lt;/h2&gt;

&lt;p&gt;The repository has two concise integration tests in &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/test/test_rate_limit.py" rel="noopener noreferrer"&gt;&lt;code&gt;test_rate_limit.py&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;with the login username-and-IP limit set to two, two bad passwords return 401 and the third attempt returns 429;&lt;/li&gt;
&lt;li&gt;with the per-minute chat limit set to two, two authenticated chat calls return 200 and the third returns 429.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both blocked responses must include &lt;code&gt;Retry-After&lt;/code&gt;. Those checks validate the observable contract rather than only the internal counter.&lt;/p&gt;

&lt;p&gt;Additional tests I would add before deploying this pattern include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;simultaneous hits at the limit boundary;&lt;/li&gt;
&lt;li&gt;key expiry followed by a fresh allowed request;&lt;/li&gt;
&lt;li&gt;separate counters for two users and two scopes;&lt;/li&gt;
&lt;li&gt;a simulated key with no TTL and the intended repair behavior;&lt;/li&gt;
&lt;li&gt;a daily-limit rejection after the minute limit succeeds;&lt;/li&gt;
&lt;li&gt;trusted-proxy and forged-forwarding-header cases;&lt;/li&gt;
&lt;li&gt;Redis timeout behavior, with an explicit fail-open or fail-closed policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/sxt12356/mini-agent/actions/runs/31559054400" rel="noopener noreferrer"&gt;public CI run for the pinned commit&lt;/a&gt; completed successfully. That is useful evidence for this exact revision, but a passing test suite does not resolve the algorithm's documented limitations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Know when to move beyond this design
&lt;/h2&gt;

&lt;p&gt;An expiry-backed counter is compact and fast. It works well when approximate fixed-window fairness is acceptable and the main goals are abuse resistance and predictable resource use.&lt;/p&gt;

&lt;p&gt;It is a weaker fit when customers require smooth quotas, globally coordinated multi-region enforcement, or billing-grade accuracy. Those cases may need a sliding-window log or counter, token bucket, gateway-level limiter, or a dedicated distributed rate-limit service. Whatever algorithm you choose, keep the same application-level disciplines: scoped identities, atomic state changes, useful 429 responses, proxy-aware client identity, and tests at the HTTP boundary.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This article describes a self-built public demonstration project, not a client production incident. It was prepared with AI-assisted editing. The author is responsible for reviewing the code, technical claims, links, and final text before publication.&lt;/em&gt;``&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>redis</category>
      <category>security</category>
    </item>
    <item>
      <title>Tracing a FastAPI AI Agent Without Logging the Whole Prompt</title>
      <dc:creator>Xitong Shan</dc:creator>
      <pubDate>Wed, 26 Aug 2026 09:24:45 +0000</pubDate>
      <link>https://dev.to/sxt12356/tracing-a-fastapi-ai-agent-without-logging-the-whole-prompt-5ch2</link>
      <guid>https://dev.to/sxt12356/tracing-a-fastapi-ai-agent-without-logging-the-whole-prompt-5ch2</guid>
      <description>&lt;p&gt;An AI-agent endpoint is harder to debug than a conventional CRUD route. A single request may load a session, call a model, run one or more tools, pause for approval, and save new state. When the final response is slow or wrong, an ordinary access log often tells you only that &lt;code&gt;POST /chat&lt;/code&gt; returned a status code.&lt;/p&gt;

&lt;p&gt;At the same time, logging the complete prompt, tool arguments, or retrieved documents is a poor default. Those values can contain customer text, credentials, or other sensitive data.&lt;/p&gt;

&lt;p&gt;This article walks through a deliberately small tracing layer from my public FastAPI project, &lt;a href="https://github.com/sxt12356/mini-agent" rel="noopener noreferrer"&gt;&lt;code&gt;mini-agent&lt;/code&gt;&lt;/a&gt;. The implementation writes one JSON object per line, keeps related operations under a shared trace ID, supports nested spans, and scrubs selected sensitive fields. It is not a replacement for OpenTelemetry, but it makes the data model and privacy decisions visible before adding a larger observability stack.&lt;/p&gt;

&lt;p&gt;The code referenced here is pinned to &lt;a href="https://github.com/sxt12356/mini-agent/tree/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640" rel="noopener noreferrer"&gt;one public commit&lt;/a&gt;, so the examples remain reproducible even if the main branch changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with correlation, not message bodies
&lt;/h2&gt;

&lt;p&gt;The tracer creates a few identifiers with different jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trace_id&lt;/code&gt; groups all recorded work for one agent request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;request_id&lt;/code&gt; connects the trace to the API request.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;session_id&lt;/code&gt; connects multiple requests in the same conversation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;span_id&lt;/code&gt; identifies one timed operation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parent_span_id&lt;/code&gt; represents nesting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That structure is more useful than a large unstructured message. It lets an operator search for one failed request, reconstruct its hierarchy, and compare durations without requiring the full user prompt.&lt;/p&gt;

&lt;p&gt;The constructor accepts IDs supplied by the application and generates the missing ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentTracer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trace_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;gen_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;gen_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;req&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the complete implementation in &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/mini_agent/core/observability.py" rel="noopener noreferrer"&gt;&lt;code&gt;observability.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model a span as a context manager
&lt;/h2&gt;

&lt;p&gt;A context manager keeps the timing and error path in one place. The caller opens a span around an operation, and the tracer emits the result in &lt;code&gt;finally&lt;/code&gt;, whether the operation succeeds or raises.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;internal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;span_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gen_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parent_span_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;start_perf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;start_time_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now_ms&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parent_span_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parent_span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stack&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;traceback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_exc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;duration_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start_perf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;popped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;popped&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;span_id&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_emit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;span_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parent_span_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parent_span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kind&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_time_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start_time_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duration_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two clocks on purpose. Wall-clock milliseconds make records searchable by time, while &lt;code&gt;time.perf_counter()&lt;/code&gt; measures elapsed time without depending on wall-clock adjustments.&lt;/p&gt;

&lt;p&gt;The stack gives a new span the current span as its parent. A model span can contain a tool span, for example. Because records are written when each context exits, a child span will normally appear in the JSONL file before its parent. Consumers should reconstruct the tree from IDs rather than assume log-line order is start order.&lt;/p&gt;

&lt;p&gt;The assertion after &lt;code&gt;pop()&lt;/code&gt; is a useful development guard, but this simple list assumes one sequential execution context. If multiple asynchronous tasks share one tracer and overlap their spans, a plain shared stack can produce the wrong parent or fail the assertion. A production version should use context-local state, such as &lt;code&gt;contextvars&lt;/code&gt;, or create separate span objects for concurrent branches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Emit events for points that do not need timing
&lt;/h2&gt;

&lt;p&gt;Not every useful observation is an interval. The same tracer also writes instantaneous events. An event uses the active span as its parent when one exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;parent_span_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_span_stack&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_emit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trace_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trace_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;request_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parent_span_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parent_span_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;now_ms&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attributes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;scrub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chat route uses an event for an unhandled endpoint error. A tool runner could use events for decisions such as &lt;code&gt;approval_requested&lt;/code&gt; or &lt;code&gt;fallback_selected&lt;/code&gt;, provided their attributes remain safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put a narrow span around the agent execution
&lt;/h2&gt;

&lt;p&gt;In the &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/mini_agent/api/routes/chat.py" rel="noopener noreferrer"&gt;&lt;code&gt;/chat&lt;/code&gt; route&lt;/a&gt;, the application creates a tracer after authentication and session-ID selection. It then wraps the synchronous &lt;code&gt;session.send&lt;/code&gt; call, which is moved to Starlette's thread pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;tracer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;AgentTracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http.post./chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;server&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;safe_preview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;has_existing_session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;run_in_threadpool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response returns &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;request_id&lt;/code&gt;. That is operationally important: a user or support engineer can report a trace ID instead of copying private conversation text into a ticket.&lt;/p&gt;

&lt;p&gt;The span name says &lt;code&gt;http.post./chat&lt;/code&gt;, but its current boundary covers the agent execution, not session loading and saving. Naming it &lt;code&gt;agent.session.send&lt;/code&gt; would be more precise, or the route could add a true outer server span and keep this as a child span. Trace names should describe the measured boundary, not merely the surrounding function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redact by default, then document the gaps
&lt;/h2&gt;

&lt;p&gt;The helper &lt;code&gt;safe_preview()&lt;/code&gt; replaces newlines and keeps at most 120 characters by default. The recursive &lt;code&gt;scrub()&lt;/code&gt; function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;replaces values whose exact, case-insensitive key is in a sensitive-key set;&lt;/li&gt;
&lt;li&gt;truncates strings longer than 500 characters;&lt;/li&gt;
&lt;li&gt;limits logged list items to the first 20;&lt;/li&gt;
&lt;li&gt;applies the same rules inside nested dictionaries and lists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is safer than serializing arbitrary inputs unchanged, but it is not a complete data-loss-prevention system. Exact-key matching will redact &lt;code&gt;token&lt;/code&gt; but not necessarily &lt;code&gt;auth_token_value&lt;/code&gt;. A short preview may still contain a name, address, or account number. Exception messages and stack traces can also expose values.&lt;/p&gt;

&lt;p&gt;For production use, I would tighten the policy in four ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disable prompt previews by default and enable them only in an explicitly safe environment.&lt;/li&gt;
&lt;li&gt;Use allow-listed attributes for each event or span instead of accepting arbitrary dictionaries.&lt;/li&gt;
&lt;li&gt;Add pattern-based secret detection and domain-specific personal-data filtering.&lt;/li&gt;
&lt;li&gt;Define retention, access control, and deletion rules for trace storage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;JSONL on local disk is convenient for development, but it also needs rotation and multi-process handling. The implementation uses one Python logger with a file and console handler; it does not provide distributed export, sampling, backpressure, or cross-service propagation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify behavior at the record boundary
&lt;/h2&gt;

&lt;p&gt;Useful checks do not need a live model. A focused test can replace the logger handler, open nested spans, and parse each emitted JSON line. It should verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;child records contain the expected &lt;code&gt;parent_span_id&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;successful and failing spans have the correct status;&lt;/li&gt;
&lt;li&gt;exceptions are re-raised after being recorded;&lt;/li&gt;
&lt;li&gt;sensitive exact keys become &lt;code&gt;[REDACTED]&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;long strings and lists are bounded;&lt;/li&gt;
&lt;li&gt;a multiline preview contains the escaped &lt;code&gt;\\n&lt;/code&gt; representation;&lt;/li&gt;
&lt;li&gt;response payloads include non-empty trace and request IDs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository's &lt;a href="https://github.com/sxt12356/mini-agent/actions/runs/31559054400" rel="noopener noreferrer"&gt;public CI run for the pinned commit&lt;/a&gt; completed successfully. The API test also checks that a successful chat response includes a trace ID; see &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/test/test_chat_api.py" rel="noopener noreferrer"&gt;&lt;code&gt;test_chat_api.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small tracer is a design exercise, not the destination
&lt;/h2&gt;

&lt;p&gt;Building this layer clarified the contract I would carry into OpenTelemetry: stable correlation IDs, explicit parent-child relationships, duration, status, bounded attributes, and a way to connect a client-visible error to an internal trace.&lt;/p&gt;

&lt;p&gt;The next step is not to keep expanding a custom tracing system indefinitely. It is to map the same boundaries to standard spans, propagate context across services and worker tasks, export through OTLP, and view latency and error distributions in an observability backend. Starting with a small implementation makes that migration easier because the privacy boundary and the meaning of each span are already explicit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This article describes a self-built public demonstration project, not a client production incident. It was prepared with AI-assisted editing. The author is responsible for reviewing the code, technical claims, links, and final text before publication.&lt;/em&gt;``&lt;/p&gt;

</description>
      <category>python</category>
      <category>fastapi</category>
      <category>observability</category>
      <category>ai</category>
    </item>
    <item>
      <title>Fixing a pgvector CI mismatch in a FastAPI RAG backend</title>
      <dc:creator>Xitong Shan</dc:creator>
      <pubDate>Sat, 22 Aug 2026 15:33:05 +0000</pubDate>
      <link>https://dev.to/sxt12356/fixing-a-pgvector-ci-mismatch-in-a-fastapi-rag-backend-3gm2</link>
      <guid>https://dev.to/sxt12356/fixing-a-pgvector-ci-mismatch-in-a-fastapi-rag-backend-3gm2</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Clear the Lineup&lt;/a&gt;, powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sxt12356/mini-agent" rel="noopener noreferrer"&gt;&lt;code&gt;mini-agent&lt;/code&gt;&lt;/a&gt; is a public FastAPI backend for an AI support-agent demo. Its test suite covers API behavior, authentication, rate limiting, approval flows, and PostgreSQL/pgvector-backed retrieval.&lt;/p&gt;

&lt;p&gt;The GitHub Actions workflow starts PostgreSQL and Redis service containers before running the Python test suite. The application database initialization also executes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;vector&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dependency is also visible in the &lt;a href="https://github.com/sxt12356/mini-agent/blob/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640/mini_agent/db/models.py#L54-L63" rel="noopener noreferrer"&gt;&lt;code&gt;DocumentChunk.embedding&lt;/code&gt;&lt;/a&gt; column, which uses pgvector's &lt;code&gt;Vector&lt;/code&gt; type. That made the database image part of the test contract, not just incidental infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;On August 12, 2026, the CI run for the preceding commit reached the test step and failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/sxt12356/mini-agent/actions/runs/31558609459" rel="noopener noreferrer"&gt;Failed workflow run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sxt12356/mini-agent/commit/e6378f7e79543a0f363d1171cca8daae7e946d55" rel="noopener noreferrer"&gt;Commit tested by that run&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The workflow was using the general-purpose &lt;code&gt;postgres:17-alpine&lt;/code&gt; service image, while the application required the pgvector extension during database initialization. The test environment therefore did not match the database capability required by the code.&lt;/p&gt;

&lt;p&gt;The failure was specific enough to avoid a broad rewrite: the container initialized successfully, dependency installation passed, and the workflow stopped only at &lt;strong&gt;Run tests&lt;/strong&gt;. That pointed to the application/database boundary rather than the GitHub Actions runner or Python installation.&lt;/p&gt;

&lt;p&gt;The fix changed one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; services:
   postgres:
&lt;span class="gd"&gt;-    image: postgres:17-alpine
&lt;/span&gt;&lt;span class="gi"&gt;+    image: pgvector/pgvector:0.8.6-pg17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full change: &lt;a href="https://github.com/sxt12356/mini-agent/commit/4f0fc4f8ecdc75288fd43eaddbeef9bc51045640" rel="noopener noreferrer"&gt;Use pgvector image in CI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The PostgreSQL major version, credentials, port mapping, health check, application environment, dependency installation, and test command all remained unchanged. This kept the patch narrow and made the CI database expose the same required extension as the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The evidence is a direct before-and-after pair:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The preceding workflow failed at &lt;strong&gt;Run tests&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The one-line database-image commit triggered a new workflow.&lt;/li&gt;
&lt;li&gt;The new run completed &lt;strong&gt;Run tests&lt;/strong&gt; successfully and the overall workflow passed.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/sxt12356/mini-agent/actions/runs/31558609459" rel="noopener noreferrer"&gt;Failed run: 31558609459&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/sxt12356/mini-agent/actions/runs/31559054400" rel="noopener noreferrer"&gt;Passing run: 31559054400&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL service image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;postgres:17-alpine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pgvector/pgvector:0.8.6-pg17&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container initialization&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency installation&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Run tests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overall workflow&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No tests were disabled, no failure was ignored, and no application feature was added to make the build green.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;The visible patch is small, but it fixes an important reliability boundary: integration tests are only meaningful when their service dependencies provide the capabilities the application actually uses.&lt;/p&gt;

&lt;p&gt;The change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;restores the full CI test run;&lt;/li&gt;
&lt;li&gt;makes the PostgreSQL service compatible with the repository's pgvector-backed model;&lt;/li&gt;
&lt;li&gt;preserves PostgreSQL 17 rather than changing database versions as a side effect;&lt;/li&gt;
&lt;li&gt;avoids installing database extensions ad hoc during every CI run;&lt;/li&gt;
&lt;li&gt;keeps the workflow readable and reproducible with a pinned pgvector image tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson is to treat database extensions as explicit runtime dependencies. If application startup creates or queries extension-backed types, CI must supply that extension too. A healthy generic PostgreSQL container is not enough when the application's schema contract includes extension-defined types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;The code change, commit timestamps, and workflow outcomes are public and independently inspectable through the links above. This write-up was prepared with AI-assisted editing and was personally reviewed by the entrant before publication. No production data, customer credentials, or private incident details are included.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>postgres</category>
      <category>python</category>
    </item>
  </channel>
</rss>
