-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 2 — Reliability, observability & evaluation: crash recovery, backpressure, metrics, evals, adversarial suite #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1f26173
070a205
b74c941
91e8c8e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # Decision record: local-tier tool calling (§8 open item) | ||
|
|
||
| **Status:** RESOLVED for Phase 2 — the local tier remains tool-free. | ||
| **Date:** Phase 2. **Revisit:** Phase 3, against the criteria below. | ||
|
|
||
| ## The question | ||
|
|
||
| §8 left one routing rule provisional: may the local (cheap, bounded) | ||
| tier be trusted to call tools, or must every tool-calling task route to | ||
| the hosted tier? | ||
|
|
||
| ## Decision | ||
|
|
||
| The local tier stays **structurally tool-free**: | ||
|
|
||
| 1. `route(requires_tools=True)` forces the hosted tier regardless of the | ||
| task's default route (`src/relay/routing/router.py`) — unchanged | ||
| since Phase 0 and now pinned by test. | ||
| 2. The backends that can serve the local tier have **no tool support by | ||
| construction** — `OpenAICompatBackend` and `GoogleGeminiBackend` | ||
| never send tool schemas; a confused or compromised local model can | ||
| produce at most one JSON blob that downstream gates re-validate. | ||
|
|
||
| So this is not a policy a prompt can argue with; there is no code path | ||
| in which a local model receives or invokes a tool. | ||
|
|
||
| ## Rationale | ||
|
|
||
| - The blast radius of a wrong tool call (writes, sends, external calls) | ||
| is categorically larger than a wrong JSON answer, and the local tier | ||
| exists precisely for tasks where being wrong is cheap. | ||
| - Small-model tool-call reliability varies wildly by model and schema; | ||
| we have no validation evidence, and "seems fine" is not evidence. | ||
| - Nothing in Phases 0–2 needs local tools: every local task is | ||
| text-in/JSON-out by design. | ||
|
|
||
| ## Revisit criteria (Phase 3) | ||
|
|
||
| Open this only when all three hold: | ||
|
|
||
| 1. A concrete task exists that needs tools AND is too high-volume for | ||
| the hosted tier's economics. | ||
| 2. A validation harness (extending `relay.evals`) measures tool-call | ||
| schema compliance and argument correctness for the candidate local | ||
| model over ≥200 golden cases, with a pass bar ≥99% for schema | ||
| compliance and an explicit review of failure modes. | ||
| 3. The tools exposed are read-only or idempotent, and every effect they | ||
| could cause remains behind the existing gates (suppression, | ||
| eligibility, human approval). | ||
|
|
||
| Until then, `requires_tools=True` → hosted, everywhere, structurally. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| """Run the reasoning evals against the CONFIGURED backends. | ||
|
|
||
| just evals # both tiers, as configured in .env | ||
| uv run python scripts/run_evals.py local | ||
| uv run python scripts/run_evals.py hosted | ||
|
|
||
| Exit code is non-zero if any evaluated backend fails its threshold — | ||
| wire this into CI or a pre-deploy check when models/prompts change. | ||
| Note: this calls the real configured providers and spends real quota. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import sys | ||
|
|
||
| from relay.compute.registry import backend_for | ||
| from relay.evals import run_evals | ||
| from relay.logs import setup_logging | ||
| from relay.routing.router import ComputeTier | ||
|
|
||
|
|
||
| def main() -> None: | ||
| setup_logging() | ||
| which = sys.argv[1] if len(sys.argv) > 1 else "both" | ||
| tiers = { | ||
| "local": [ComputeTier.LOCAL], | ||
| "hosted": [ComputeTier.HOSTED], | ||
| "both": [ComputeTier.LOCAL, ComputeTier.HOSTED], | ||
| }[which] | ||
|
|
||
| failed = False | ||
| for tier in tiers: | ||
| backend = backend_for(tier) | ||
| report = run_evals(backend) | ||
| print(f"\n─── {tier} tier: {report.backend}:{report.model} ───") | ||
| for category, (passed, total) in sorted(report.by_category.items()): | ||
| marker = "✓" if passed == total else "✗" | ||
| print(f" {marker} {category:16s} {passed}/{total}") | ||
| verdict = "PASS" if report.passed else "FAIL" | ||
| print( | ||
| f" → {verdict} (pass rate {report.pass_rate:.0%}, " | ||
| f"threshold {report.threshold:.0%})" | ||
| ) | ||
| for r in report.failures: | ||
| print(f" ✗ {r.case_id}: {r.detail}") | ||
| failed = failed or not report.passed | ||
|
|
||
| sys.exit(1 if failed else 0) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| """The minimal ops dashboard — one self-contained HTML page, like /review. | ||
|
|
||
| Reads /metrics and /alerts with the tenant key entered in the page. | ||
| No build step, no external assets; deliberately a glanceable status | ||
| board, not a BI tool (Grafana over /metrics/prometheus is the Phase 3 | ||
| answer for real dashboards). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| OPS_PAGE = """<!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title>RELAY — Ops</title> | ||
| <style> | ||
| :root { font-family: system-ui, sans-serif; color: #1a1a1a; } | ||
| body { margin: 0; padding: 1.5rem 2rem; max-width: 1100px; } | ||
| h1 { font-size: 1.15rem; } | ||
| .bar { display: flex; gap: .6rem; margin-bottom: 1.2rem; } | ||
| input { font: inherit; padding: .45rem; flex: 1; } | ||
| button { font: inherit; padding: .45rem 1rem; cursor: pointer; | ||
| border: 1px solid #999; border-radius: 6px; background: #fff; } | ||
| .grid { display: grid; grid-template-columns: repeat(auto-fill, | ||
| minmax(240px, 1fr)); gap: 1rem; } | ||
| .card { border: 1px solid #ddd; border-radius: 8px; padding: .9rem 1.1rem; } | ||
| .card h2 { font-size: .8rem; text-transform: uppercase; color: #666; | ||
| margin: 0 0 .5rem; letter-spacing: .04em; } | ||
| .big { font-size: 1.6rem; font-weight: 650; } | ||
| table { width: 100%; border-collapse: collapse; font-size: .85rem; } | ||
| td { padding: .15rem 0; } td:last-child { text-align: right; | ||
| font-variant-numeric: tabular-nums; } | ||
| .alert { border-left: 4px solid #c62828; background: #fdecea; | ||
| padding: .6rem .9rem; border-radius: 4px; margin-bottom: .5rem; } | ||
| .alert.warning { border-color: #b26a00; background: #fff8e1; } | ||
| .ok { color: #2e7d32; } | ||
| #updated { color: #777; font-size: .8rem; margin-left: auto; | ||
| align-self: center; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <h1>RELAY ops</h1> | ||
| <div class="bar"> | ||
| <input type="password" id="key" placeholder="Tenant API key (rk_…)"> | ||
| <button onclick="refresh()">Refresh</button> | ||
| <label style="align-self:center;font-size:.85rem"> | ||
| <input type="checkbox" id="auto" onchange="autoRefresh()"> auto (30s) | ||
| </label> | ||
| <span id="updated"></span> | ||
| </div> | ||
| <div id="alerts"></div> | ||
| <div class="grid" id="cards"></div> | ||
| <script> | ||
| const $ = (id) => document.getElementById(id); | ||
| $("key").value = sessionStorage.getItem("relay_key") || ""; | ||
| let timer = null; | ||
|
|
||
| function esc(s) { | ||
| return String(s).replace(/[&<>"']/g, | ||
| (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]); | ||
| } | ||
|
|
||
| function table(obj) { | ||
| const rows = Object.entries(obj).sort() | ||
| .map(([k, v]) => `<tr><td>${esc(k)}</td><td>${v}</td></tr>`).join(""); | ||
| return rows ? `<table>${rows}</table>` : "<em>none</em>"; | ||
| } | ||
|
|
||
| async function refresh() { | ||
| const headers = { "X-API-Key": $("key").value.trim() }; | ||
| sessionStorage.setItem("relay_key", $("key").value.trim()); | ||
| const [mRes, aRes] = await Promise.all([ | ||
| fetch("/metrics", { headers }), fetch("/alerts", { headers }), | ||
| ]); | ||
| if (!mRes.ok) { $("cards").innerHTML = `Error ${mRes.status}`; return; } | ||
| const m = await mRes.json(); | ||
| const a = aRes.ok ? await aRes.json() : { alerts: [] }; | ||
|
|
||
| $("alerts").innerHTML = a.alerts.length | ||
| ? a.alerts.map(x => `<div class="alert ${x.severity}"> | ||
| <b>${esc(x.rule)}</b> — ${esc(x.detail)}</div>`).join("") | ||
| : `<div class="ok">No active alerts.</div>`; | ||
|
|
||
| const errRate = m.run_error_rate == null ? "–" | ||
| : (m.run_error_rate * 100).toFixed(0) + "%"; | ||
| const replyRate = m.reply_rate == null ? "–" | ||
| : (m.reply_rate * 100).toFixed(0) + "%"; | ||
|
|
||
| $("cards").innerHTML = ` | ||
| <div class="card"><h2>Cost (24h)</h2> | ||
| <div class="big">${m.cost_units_window.toFixed(1)}</div> units</div> | ||
| <div class="card"><h2>Run error rate (24h)</h2> | ||
| <div class="big">${errRate}</div></div> | ||
| <div class="card"><h2>Sent / replies (24h)</h2> | ||
| <div class="big">${m.sent_window} / ${m.replies_window}</div> | ||
| reply rate ${replyRate}</div> | ||
| <div class="card"><h2>Suppression entries</h2> | ||
| <div class="big">${m.suppression_entries}</div></div> | ||
| <div class="card"><h2>Leads by state</h2>${table(m.lead_states)}</div> | ||
| <div class="card"><h2>Runs (24h)</h2>${table(m.runs_window)}</div> | ||
| <div class="card"><h2>Send jobs</h2>${table(m.send_jobs)}</div>`; | ||
| $("updated").textContent = | ||
| "updated " + new Date().toLocaleTimeString(); | ||
| } | ||
|
|
||
| function autoRefresh() { | ||
| if (timer) { clearInterval(timer); timer = null; } | ||
| if ($("auto").checked) timer = setInterval(refresh, 30000); | ||
| } | ||
| </script> | ||
| </body> | ||
| </html> | ||
| """ |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |||||||||||||||||||||||||||||||||||
| from relay.guardrails.harness import GuardrailViolation | ||||||||||||||||||||||||||||||||||||
| from relay.hashing import email_domain, hash_api_key, hash_email | ||||||||||||||||||||||||||||||||||||
| from relay.logs import get_logger | ||||||||||||||||||||||||||||||||||||
| from relay.observability import evaluate_alerts, prometheus_text, tenant_metrics | ||||||||||||||||||||||||||||||||||||
| from relay.pipeline.runner import PipelineRunner | ||||||||||||||||||||||||||||||||||||
| from relay.workers.send_worker import process_pending | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -502,6 +503,61 @@ def economics( | |||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # ── Observability (Phase 2) ───────────────────────────────────────────────── | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @router.get("/metrics", response_model=schemas.MetricsResponse) | ||||||||||||||||||||||||||||||||||||
| def metrics_json( | ||||||||||||||||||||||||||||||||||||
| tenant_id: uuid.UUID = Depends(require_tenant), | ||||||||||||||||||||||||||||||||||||
| ) -> schemas.MetricsResponse: | ||||||||||||||||||||||||||||||||||||
| m = tenant_metrics(tenant_id) | ||||||||||||||||||||||||||||||||||||
| return schemas.MetricsResponse( | ||||||||||||||||||||||||||||||||||||
| tenant_id=m.tenant_id, | ||||||||||||||||||||||||||||||||||||
| generated_at=m.generated_at, | ||||||||||||||||||||||||||||||||||||
| lead_states=m.lead_states, | ||||||||||||||||||||||||||||||||||||
| runs_window=m.runs, | ||||||||||||||||||||||||||||||||||||
| cost_units_window=m.cost_units_window, | ||||||||||||||||||||||||||||||||||||
| send_jobs=m.send_jobs, | ||||||||||||||||||||||||||||||||||||
| replies_window=m.replies_window, | ||||||||||||||||||||||||||||||||||||
| sent_window=m.sent_window, | ||||||||||||||||||||||||||||||||||||
| suppression_entries=m.suppression_entries, | ||||||||||||||||||||||||||||||||||||
| run_error_rate=m.run_error_rate, | ||||||||||||||||||||||||||||||||||||
| reply_rate=m.reply_rate, | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @router.get("/metrics/prometheus", include_in_schema=False) | ||||||||||||||||||||||||||||||||||||
| def metrics_prometheus( | ||||||||||||||||||||||||||||||||||||
| tenant_id: uuid.UUID = Depends(require_tenant), | ||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||
| from fastapi.responses import PlainTextResponse | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return PlainTextResponse(prometheus_text(tenant_metrics(tenant_id))) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+529
to
+535
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the route and nearby tests/usages
git ls-files src/relay/api/routes.py
wc -l src/relay/api/routes.py
sed -n '500,560p' src/relay/api/routes.py
printf '\n--- search for metrics route tests/usages ---\n'
rg -n "metrics/prometheus|prometheus_text|tenant_metrics|PlainTextResponse|content-type|Content-Type" src tests . -g '!**/.git/**'Repository: Aakash1337/Relay Length of output: 5317 🌐 Web query:
💡 Result: In Prometheus 3.0, providing an accurate Content-Type header for scrape targets is mandatory; if the header is missing, unparsable, or unrecognized, the scrape will fail [1][2]. For the Prometheus text format version 0.0.4, the official MIME type and parameters are defined as: Content-Type: text/plain; version=0.0.4 [3][4] While some internal implementations or client libraries may include charset=utf-8 (e.g., text/plain; version=0.0.4; charset=utf-8) [5], Prometheus is strict regarding the Content-Type parsing in version 3.0 [6]. If your scrape target is currently returning a header that causes a failure—such as text/plain; version=0.0.4; charset=utf-8—and you cannot change the target's output, you can configure a fallback scrape protocol on the Prometheus side [6]. Key requirements for Prometheus 3.0 scrape targets: 1. Strict Content-Type: You must return a valid Content-Type header that includes the appropriate MIME type and version parameter [3][1]. 2. Fallback Configuration: If your target returns a non-standard or unsupported Content-Type string (such as one with additional charset parameters that the parser does not expect), you can define a fallback_scrape_protocol in your Prometheus configuration for that specific target to ensure the scrape succeeds [6]. 3. Recommended Format: The preferred text format for modern Prometheus usage is version 1.0.0 (text/plain; version=1.0.0; escaping=allow-utf-8) or the OpenMetrics format, as version 0.0.4 is considered a legacy fallback [3][4]. Targets should expose metrics using only one specific content type [1][2]. Citations:
🌐 Web query:
💡 Result: The Prometheus text exposition format version 0.0.4 is designed to use the HTTP Content-Type header: text/plain; version=0.0.4; charset=utf-8 [1][2][3]. Key details regarding this specification and its parsing include: 1. Requirement for Semicolons: According to standard MIME media type definitions (as implemented by libraries like Go's mime package), media parameters must be separated by semicolons (;) [4]. A common error occurs when implementations incorrectly use commas (,) instead of semicolons to separate the charset parameter (e.g., text/plain; version=0.0.4, charset=utf-8), which causes parsing failures in strict environments such as Prometheus v3 [4]. 2. Charset Standard: The charset is universally expected to be UTF-8 [1][2]. While earlier discussions considered the explicit inclusion of the charset parameter [5], it has become standard practice in Prometheus client libraries to include; charset=utf-8 as part of the Content-Type string to ensure clarity and interoperability [2][6][3]. 3. Versioning: The version=0.0.4 parameter explicitly identifies the Prometheus text exposition format [1]. If the version parameter is missing, Prometheus servers may fall back to the most recent text format version [1]. 4. Troubleshooting Parsing Errors: If a scrape target provides an invalid Content-Type string (such as one using commas instead of semicolons), Prometheus v3 will fail to parse it [4]. In such cases, one must either correct the exporter's header generation or configure the Prometheus scrape job with the fallback_scrape_protocol=PrometheusText0.0.4 setting to bypass the strict header parsing [4]. Citations:
🌐 Web query:
💡 Result: In Prometheus 3.0, the scrape parser requires that targets return a valid and supported Content-Type header [1][2]. While Prometheus text format version 0.0.4 is a supported format (identified by the MIME type text/plain with the parameter version=0.0.4) [3][4], the parser expects strict adherence to the defined Content-Type specifications [1][5]. Some users have reported that sending a Content-Type header formatted as text/plain; charset=utf-8 (without the required version parameter) fails in Prometheus 3.0 because it does not match the expected format string [6]. The parser in Prometheus 3.0 is more strict than in version 2.0; it no longer silently falls back to the classic text format if the header is missing, unparsable, or unrecognized [1][2][7]. If a scrape target provides an invalid or missing Content-Type, the scrape will fail unless a fallback_scrape_protocol is explicitly configured in the Prometheus scrape configuration [1][2][5]. To ensure compatibility with Prometheus 3.0, endpoints should return the correct Content-Type header, such as: text/plain;version=0.0.4 [3][1] If a target cannot be updated to provide the exact header, you can configure the scrape job in Prometheus to use the appropriate fallback: scrape_configs: - job_name: 'example' fallback_scrape_protocol: PrometheusText0.0.4 scrape_configs:... [1][2][8] Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the observability text format and the current test expectations.
sed -n '1,220p' src/relay/observability/metrics.py
printf '\n--- tests ---\n'
sed -n '1,220p' tests/test_observability.pyRepository: Aakash1337/Relay Length of output: 9389 🌐 Web query:
💡 Result: In Starlette, the PlainTextResponse class inherits from the base Response class [1]. Its default media_type is set to "text/plain" [1]. When a response has a media type starting with "text/" (which includes "text/plain"), Starlette automatically appends a charset parameter to the Content-Type header if one is not already present [2][3][4]. By default, this charset is "utf-8" [1]. Therefore, a standard PlainTextResponse will typically result in a Content-Type header value of "text/plain; charset=utf-8". Starlette is designed to avoid appending a duplicate charset if you have already explicitly included one in the media_type parameter [3][5]. Citations:
Set the Prometheus exposition Content-Type explicitly.
🔧 Proposed fix from fastapi.responses import PlainTextResponse
- return PlainTextResponse(prometheus_text(tenant_metrics(tenant_id)))
+ return PlainTextResponse(
+ prometheus_text(tenant_metrics(tenant_id)),
+ media_type="text/plain; version=0.0.4; charset=utf-8",
+ )📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @router.get("/alerts", response_model=schemas.AlertsResponse) | ||||||||||||||||||||||||||||||||||||
| def alerts( | ||||||||||||||||||||||||||||||||||||
| tenant_id: uuid.UUID = Depends(require_tenant), | ||||||||||||||||||||||||||||||||||||
| ) -> schemas.AlertsResponse: | ||||||||||||||||||||||||||||||||||||
| fired = evaluate_alerts(tenant_id) | ||||||||||||||||||||||||||||||||||||
| return schemas.AlertsResponse( | ||||||||||||||||||||||||||||||||||||
| tenant_id=tenant_id, | ||||||||||||||||||||||||||||||||||||
| alerts=[ | ||||||||||||||||||||||||||||||||||||
| schemas.AlertItem( | ||||||||||||||||||||||||||||||||||||
| rule=a.rule, severity=a.severity, detail=a.detail, value=a.value | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| for a in fired | ||||||||||||||||||||||||||||||||||||
| ], | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @router.get("/ops", include_in_schema=False) | ||||||||||||||||||||||||||||||||||||
| def ops_page() -> HTMLResponse: | ||||||||||||||||||||||||||||||||||||
| from relay.api.ops_ui import OPS_PAGE | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| return HTMLResponse(OPS_PAGE) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| # ── DSR erasure (tenant-scoped): the right to be forgotten ────────────────── | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Aakash1337/Relay
Length of output: 260
🏁 Script executed:
Repository: Aakash1337/Relay
Length of output: 6245
🏁 Script executed:
Repository: Aakash1337/Relay
Length of output: 1026
🏁 Script executed:
Repository: Aakash1337/Relay
Length of output: 4365
🌐 Web query:
python-dotenv empty value inline comment parses comment as value bug 1.2.2💡 Result:
In version 1.2.2 of python-dotenv, there is a known parsing behavior where an empty unquoted value followed by an inline comment is incorrectly parsed as including the comment text as part of the value [1]. For example, the line KEY= # comment is parsed as {'KEY': '# comment'} rather than {'KEY': ''} [2][1]. This issue arises because the parser's equal sign regex (
=[^\S\r\n]*) consumes the whitespace immediately following the equals sign [3][2]. Consequently, the subsequent comment parsing logic—which relies on finding whitespace before the#character—fails to detect the comment, causing it to be treated as part of the unquoted value instead [3][2][1]. This behavior is tracked as an issue (Issue#600) and was addressed in subsequent development through pull requests that adjust the parser logic to correctly identify and strip inline comments even when the preceding value is empty [4][3][2]. Users encountering this can either avoid inline comments on lines with empty values or use quoted empty values (e.g., KEY="") as a workaround, which are parsed correctly [3].Citations:
KEY= # comment) theskumar/python-dotenv#600Quote the empty webhook value
RELAY_ALERT_WEBHOOK_URL=is loaded throughpydantic-settingswithpython-dotenv1.2.2, where an unquoted empty value plus an inline comment can be parsed as the comment text. That can makealert_webhook_urlnon-empty and break the “empty = log only” fallback.🛠️ Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 96-96: [ValueWithoutQuotes] This value needs to be surrounded in quotes
(ValueWithoutQuotes)
🤖 Prompt for AI Agents
Source: Linters/SAST tools