Free SKILL.md scraped from GitHub. Clone the repo or copy the file directly into your Claude Code skills directory.
npx versuz@latest install leoyeai-openclaw-master-skills-skills-phy-otel-auditgit clone https://github.com/LeoYeAI/openclaw-master-skills.gitcp openclaw-master-skills/SKILL.MD ~/.claude/skills/leoyeai-openclaw-master-skills-skills-phy-otel-audit/SKILL.md---
name: phy-otel-audit
description: OpenTelemetry instrumentation coverage auditor. Scans Node.js/Python/Go/Java source code to detect missing or misconfigured OTel instrumentation — HTTP handlers without spans, database calls outside trace context, missing resource attributes, span errors not recorded, baggage not propagated, SDK not initialized before first import, sampler misconfiguration, and more. Outputs a per-file coverage score and actionable fix snippets. Zero external dependencies.
license: Apache-2.0
metadata:
author: PHY041
version: "1.0.0"
tags:
- observability
- opentelemetry
- otel
- tracing
- metrics
- instrumentation
- static-analysis
- zero-deps
- node
- python
- golang
- java
---
# phy-otel-audit — OpenTelemetry Instrumentation Auditor
Scans your source code for **10 classes of missing or misconfigured OpenTelemetry instrumentation** that cause invisible blind spots in your traces: unspanned HTTP handlers, DB calls outside trace context, swallowed span errors, missing service.name attributes, and more.
## Quick Start
```bash
# Scan a directory
python otel_audit.py ./src
# Single file
python otel_audit.py src/handlers/users.js
# CI mode — exit 1 on HIGH findings
python otel_audit.py ./src --ci
# Verbose: show which line triggered each finding
python otel_audit.py ./src --verbose
# Only HIGH findings
python otel_audit.py ./src --only-severity HIGH
```
## The 10 Checks
| ID | Severity | Check |
|----|----------|-------|
| OT001 | HIGH | No OTel SDK imported anywhere — zero instrumentation |
| OT002 | HIGH | HTTP handler without span creation |
| OT003 | HIGH | Database/cache call outside active span |
| OT004 | HIGH | Exception caught but span.recordException() not called |
| OT005 | MEDIUM | service.name not set in Resource attributes |
| OT006 | MEDIUM | OTel SDK initialized after first import (instrumentation gap) |
| OT007 | MEDIUM | Span created but status not set on error path |
| OT008 | MEDIUM | Async context propagation missing (Promise/goroutine context not passed) |
| OT009 | LOW | Trace exporter using console/stdout in non-dev environment |
| OT010 | LOW | Manual span naming uses dynamic values (high-cardinality span names) |
### OT001 — No OTel SDK Imported
Scans all files for any OpenTelemetry import. If none found, zero instrumentation exists.
**Detected imports:**
- JS/TS: `@opentelemetry/api`, `@opentelemetry/sdk-node`, `@opentelemetry/auto-instrumentations-node`
- Python: `opentelemetry`, `opentelemetry-sdk`, `from opentelemetry`
- Go: `go.opentelemetry.io/otel`
- Java: `io.opentelemetry`, `opentelemetry-java`
### OT002 — HTTP Handler Without Span
Finds route handler definitions (Express, FastAPI, Flask, gin, Spring) without a `tracer.startSpan` or `tracer.startActiveSpan` nearby. Auto-instrumentation covers framework-level spans, but business logic within handlers needs custom spans for meaningful traces.
### OT003 — Database/Cache Call Outside Span
Detects DB/cache operations (`db.query`, `prisma.`, `mongoose.`, `cursor.execute`, `db.Execute`, `redis.get`, `cache.get`) that appear in functions where no span context is active (no `tracer.startActiveSpan`, no `ctx` parameter carrying trace context, no `with tracer.start_as_current_span`).
### OT004 — Exception Not Recorded on Span
Finds `catch` blocks or `except` clauses that handle errors but don't call `span.recordException(err)` and `span.setStatus({ code: SpanStatusCode.ERROR })`. Unrecorded exceptions make traces appear successful when they failed — the most common OTel mistake.
### OT005 — Missing service.name Resource
Scans OTel SDK initialization code for `Resource.create` or `resource:` config without `service.name`. Without `service.name`, all traces appear as `unknown_service` in backends (Jaeger/Tempo/Honeycomb) — impossible to filter.
### OT006 — SDK Initialized After First Import
In Node.js, `require('@opentelemetry/sdk-node')` must happen before any other `require` statements. If SDK init file is imported after other modules, auto-instrumentation patches miss the already-loaded modules. Detects `tracing.js` or `instrumentation.js` imported after other modules in entry files.
### OT007 — Span Status Not Set on Error Path
Finds `span.end()` calls in error branches (catch blocks, error handlers) without a preceding `span.setStatus(SpanStatusCode.ERROR)` or `span.setStatus({ code: 2 })`. Span ends without status = treated as OK by the backend.
### OT008 — Context Not Propagated Through Async
Finds `Promise.all(`, `asyncio.gather(`, or goroutine `go func()` patterns where the OTel context is not explicitly passed. In Go, `context.Context` must be threaded through goroutines manually. In Python asyncio, OpenTelemetry context is propagated automatically via contextvars — but only if tasks are created from within an active span.
### OT009 — Console/Stdout Exporter in Production
Finds `ConsoleSpanExporter`, `SimpleSpanProcessor(new ConsoleSpanExporter())`, or `ConsoleMetricExporter` outside of dev/test configuration files. Console exporters flood logs and provide no tracing backend value in production.
### OT010 — High-Cardinality Span Names
Finds `tracer.startSpan(` with dynamic values in the span name (string interpolation with variables, request paths with IDs). High-cardinality span names (`GET /users/12345`) break trace aggregation — span names should be templates (`GET /users/{id}`).
## Sample Output
```
============================================================
OTel Instrumentation Audit — src/
Files scanned: 52 | Files with issues: 9
============================================================
── HIGH (3) ────────────────────────────────────────────────
🟠 OT001 [HIGH] <project>
No OpenTelemetry SDK imported anywhere. Zero instrumentation.
Fix: npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node
Create instrumentation.js and require it first in your entry point.
🟠 OT004 [HIGH] src/handlers/orders.js:78
Exception caught in catch block but span.recordException() not called.
Fix: catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR }); throw err; }
🟠 OT002 [HIGH] src/routes/payments.js:12
HTTP handler (app.post /checkout) has no custom span. Business logic is a trace black box.
Fix: tracer.startActiveSpan('checkout.process', async (span) => { ... span.end(); })
── MEDIUM (2) ──────────────────────────────────────────────
🟡 OT005 [MEDIUM] src/tracing.js:8
OTel Resource initialized without service.name attribute.
Fix: Resource.create({ [SemanticResourceAttributes.SERVICE_NAME]: 'payment-service' })
🟡 OT007 [MEDIUM] src/services/inventory.js:45
span.end() called in error branch without span.setStatus(SpanStatusCode.ERROR).
Fix: span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); span.end();
── LOW (1) ─────────────────────────────────────────────────
🔵 OT010 [LOW] src/handlers/users.js:23
High-cardinality span name: tracer.startSpan(`GET /users/${userId}`)
Fix: Use template: tracer.startSpan('GET /users/{id}') and set userId as span attribute.
────────────────────────────────────────────────────────────
Total: 6 findings
High: 3 | Medium: 2 | Low: 1
❌ CI GATE FAILED — resolve HIGH findings before merging.
```
## The Script
```python
#!/usr/bin/env python3
"""
phy-otel-audit — OpenTelemetry Instrumentation Coverage Auditor
Scans Node.js/Python/Go/Java for missing or misconfigured OTel instrumentation.
Zero external dependencies.
"""
import sys
import re
from dataclasses import dataclass, field
from pathlib import Path
# ─── Data Structures ─────────────────────────────────────────────────────────
@dataclass
class Finding:
check_id: str
severity: str # HIGH / MEDIUM / LOW
location: str
message: str
fix: str = ""
def __str__(self) -> str:
icon = {"HIGH": "🟠", "MEDIUM": "🟡", "LOW": "🔵"}.get(self.severity, "⚪")
parts = [f"{icon} {self.check_id} [{self.severity}] {self.location}"]
parts.append(f" {self.message}")
if self.fix:
parts.append(f" Fix: {self.fix}")
return "\n".join(parts)
@dataclass
class AuditResult:
scan_root: str
files_scanned: int = 0
files_flagged: int = 0
findings: list = field(default_factory=list)
@property
def high_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "HIGH")
@property
def medium_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "MEDIUM")
@property
def low_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "LOW")
# ─── Constants ────────────────────────────────────────────────────────────────
OTEL_IMPORT_RE = re.compile(
r"(@opentelemetry/api|@opentelemetry/sdk-node|@opentelemetry/auto-instrumentations|"
r"from opentelemetry|import opentelemetry|go\.opentelemetry\.io/otel|"
r"io\.opentelemetry|opentelemetry-java)",
re.IGNORECASE,
)
HTTP_HANDLER_RE = re.compile(
r"""(app|router|r|e|g|mux)\.(get|post|put|patch|delete|handle)\s*\(\s*['"`/]""",
re.IGNORECASE,
)
PYTHON_ROUTE_RE = re.compile(
r"""@(app|router|blueprint)\.(get|post|put|patch|delete|route)\s*\(""",
re.IGNORECASE,
)
GO_ROUTE_RE = re.compile(
r"""(router|r|mux|e)\.(GET|POST|PUT|PATCH|DELETE|Handle)\s*\(""",
)
SPRING_HANDLER_RE = re.compile(
r"@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)",
)
DB_CALL_RE = re.compile(
r"(db\.query|db\.execute|db\.find|db\.save|db\.update|db\.delete|"
r"prisma\.\w+\.(find|create|update|delete|upsert)|"
r"mongoose\.\w+\.(find|save|update|delete)|"
r"cursor\.execute|session\.execute|"
r"db\.Execute|db\.Query|db\.QueryRow|"
r"redis\.(get|set|del|hget|hset)|"
r"cache\.(get|set|delete)|"
r"\.findById|\.findOne|\.findAll|\.save\(\)|\.delete\(\))",
re.IGNORECASE,
)
SPAN_ACTIVE_RE = re.compile(
r"(tracer\.(startActiveSpan|startSpan)|"
r"with\s+tracer\.start_as_current_span|"
r"otel\.trace\.getActiveSpan|"
r"span\s*:?=\s*tracer\.Start|"
r"@WithSpan|@NewSpan)",
re.IGNORECASE,
)
CATCH_BLOCK_RE = re.compile(r"(catch\s*\(|except\s+\w|except\s*:)", re.IGNORECASE)
EXCEPTION_RECORD_RE = re.compile(
r"(span\.recordException|span\.record_exception|span\.RecordError)",
re.IGNORECASE,
)
SPAN_STATUS_ERROR_RE = re.compile(
r"(span\.setStatus|span\.set_status|span\.SetStatus)",
re.IGNORECASE,
)
RESOURCE_INIT_RE = re.compile(
r"(Resource\.create|Resource\.default\(\)|new Resource|resource\.New)",
re.IGNORECASE,
)
SERVICE_NAME_RE = re.compile(
r"(SERVICE_NAME|service\.name|service_name\s*:|\"service\.name\")",
re.IGNORECASE,
)
CONSOLE_EXPORTER_RE = re.compile(
r"(ConsoleSpanExporter|ConsoleMetricExporter|ConsoleLogRecordExporter|"
r"StdoutSpanExporter|stdout_span_exporter)",
re.IGNORECASE,
)
ASYNC_CONCURRENT_RE = re.compile(
r"(Promise\.all\s*\(|Promise\.allSettled\s*\(|asyncio\.gather\s*\(|"
r"go\s+func\s*\(|go\s+\w+\()",
re.IGNORECASE,
)
CONTEXT_PASS_RE = re.compile(
r"(context\.TODO\(\)|context\.Background\(\)|ctx,|propagation\.|"
r"W3CTraceContextPropagator|TraceContext\.inject|extract\()",
re.IGNORECASE,
)
SPAN_START_WITH_TEMPLATE_RE = re.compile(
r"""tracer\.(startSpan|startActiveSpan)\s*\(\s*[`'"].*(\$\{|\%s|\+\s*\w+)""",
)
SPAN_END_RE = re.compile(r"span\.(end|End)\(\)", re.IGNORECASE)
DEV_CONFIG_PATTERN = re.compile(
r"(dev|development|test|local|debug)",
re.IGNORECASE,
)
OTEL_INIT_FILE_RE = re.compile(
r"(tracing|instrumentation|telemetry|otel)(\.(js|ts|py))?$",
re.IGNORECASE,
)
REQUIRE_RE = re.compile(r"require\s*\(['\"]([^'\"]+)['\"]\)")
SUPPORTED_EXTENSIONS = {".js", ".ts", ".jsx", ".tsx", ".mjs", ".py", ".go", ".java", ".kt"}
SKIP_DIRS = {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build", ".next", "vendor", "test", "tests", "__tests__"}
# ─── Helpers ─────────────────────────────────────────────────────────────────
def get_context_lines(lines: list, idx: int, window: int = 20) -> str:
start = max(0, idx - window)
end = min(len(lines), idx + window)
return "\n".join(lines[start:end])
def collect_files(path: str) -> list:
p = Path(path)
if p.is_file():
return [p] if p.suffix in SUPPORTED_EXTENSIONS else []
files = []
for f in p.rglob("*"):
if any(skip in f.parts for skip in SKIP_DIRS):
continue
if f.is_file() and f.suffix in SUPPORTED_EXTENSIONS:
files.append(f)
return files
def is_dev_config_file(filepath: str) -> bool:
"""Heuristic: is this a dev/test config file?"""
name = Path(filepath).name.lower()
return bool(DEV_CONFIG_PATTERN.search(name))
# ─── Checks ──────────────────────────────────────────────────────────────────
def check_ot001_no_otel_sdk(all_contents: dict) -> list:
"""OT001 — No OTel SDK imported anywhere."""
for content in all_contents.values():
if OTEL_IMPORT_RE.search(content):
return []
return [Finding(
check_id="OT001",
severity="HIGH",
location="<project>",
message="No OpenTelemetry SDK imported anywhere. Zero instrumentation — no traces, metrics, or logs exported.",
fix=(
"JS: npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node\n"
" Python: pip install opentelemetry-sdk opentelemetry-exporter-otlp\n"
" Go: go get go.opentelemetry.io/otel go.opentelemetry.io/otel/exporters/otlp/otlptrace"
),
)]
def check_ot002_handler_no_span(filepath: str, lines: list) -> list:
"""OT002 — HTTP handler without custom span."""
findings = []
for i, line in enumerate(lines):
is_handler = (
HTTP_HANDLER_RE.search(line) or
PYTHON_ROUTE_RE.search(line) or
GO_ROUTE_RE.search(line) or
SPRING_HANDLER_RE.search(line)
)
if not is_handler:
continue
ctx = get_context_lines(lines, i, 30)
if not SPAN_ACTIVE_RE.search(ctx):
findings.append(Finding(
check_id="OT002",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"HTTP handler '{line.strip()[:70]}' has no custom span. Business logic inside is a trace black box.",
fix="tracer.startActiveSpan('handler.name', async (span) => { try { ... } finally { span.end(); } })",
))
return findings
def check_ot003_db_outside_span(filepath: str, lines: list) -> list:
"""OT003 — DB/cache call where no span context is active in scope."""
findings = []
for i, line in enumerate(lines):
if not DB_CALL_RE.search(line):
continue
ctx = get_context_lines(lines, i, 30)
if not SPAN_ACTIVE_RE.search(ctx):
findings.append(Finding(
check_id="OT003",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"DB/cache call '{line.strip()[:70]}' outside an active span — DB timing won't appear in traces.",
fix="Wrap DB calls inside tracer.startActiveSpan('db.query') or ensure callers always create a span first.",
))
return findings
def check_ot004_exception_not_recorded(filepath: str, lines: list) -> list:
"""OT004 — Exception caught without span.recordException()."""
findings = []
for i, line in enumerate(lines):
if not CATCH_BLOCK_RE.search(line):
continue
ctx = get_context_lines(lines, i, 15)
# Only flag if there's a span in scope
if not SPAN_ACTIVE_RE.search(ctx) and "span" not in ctx.lower():
continue
if not EXCEPTION_RECORD_RE.search(ctx):
findings.append(Finding(
check_id="OT004",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message="Exception caught inside a span scope but span.recordException() not called. Trace appears successful on error.",
fix="catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); throw err; }",
))
return findings
def check_ot005_no_service_name(filepath: str, lines: list, content: str) -> list:
"""OT005 — OTel Resource initialized without service.name."""
if not RESOURCE_INIT_RE.search(content):
return []
if SERVICE_NAME_RE.search(content):
return []
# Resource init found but no service.name
for i, line in enumerate(lines):
if RESOURCE_INIT_RE.search(line):
return [Finding(
check_id="OT005",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message="OTel Resource initialized without service.name. All traces appear as 'unknown_service' in Jaeger/Tempo/Honeycomb.",
fix="Resource.create({ [SemanticResourceAttributes.SERVICE_NAME]: 'my-service-name' })",
)]
return []
def check_ot006_sdk_late_init(filepath: str, lines: list) -> list:
"""OT006 — OTel SDK initialized after other imports (Node.js only)."""
if not filepath.endswith((".js", ".ts", ".mjs")):
return []
# Only check entry files (server.js, index.js, app.js, main.js)
name = Path(filepath).stem.lower()
if name not in ("server", "index", "app", "main", "start"):
return []
requires = []
for i, line in enumerate(lines):
m = REQUIRE_RE.search(line)
if m:
requires.append((i, m.group(1)))
otel_idx = None
first_other_idx = None
for idx, module in requires:
if "opentelemetry" in module or "tracing" in module or "instrumentation" in module:
if otel_idx is None:
otel_idx = idx
else:
if first_other_idx is None:
first_other_idx = idx
if otel_idx is not None and first_other_idx is not None and otel_idx > first_other_idx:
return [Finding(
check_id="OT006",
severity="MEDIUM",
location=f"{filepath}:{otel_idx + 1}",
message="OTel SDK initialized AFTER other imports. Auto-instrumentation patches miss already-loaded modules.",
fix="Move require('./tracing') to the very first line of your entry file, before any other require().",
)]
return []
def check_ot007_span_end_no_status(filepath: str, lines: list) -> list:
"""OT007 — span.end() in error branch without setStatus(ERROR)."""
findings = []
for i, line in enumerate(lines):
if not SPAN_END_RE.search(line):
continue
# Check if inside an error branch
ctx_before = "\n".join(lines[max(0, i - 10):i])
if not re.search(r"(catch|except|error|err|Error|Exception)", ctx_before, re.IGNORECASE):
continue
if not SPAN_STATUS_ERROR_RE.search(ctx_before):
findings.append(Finding(
check_id="OT007",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message="span.end() in error branch without span.setStatus(SpanStatusCode.ERROR). Span marked OK despite error.",
fix="span.setStatus({ code: SpanStatusCode.ERROR, message: err.message }); span.end();",
))
return findings
def check_ot008_async_context_not_passed(filepath: str, lines: list) -> list:
"""OT008 — Concurrent async without context propagation (Go goroutines)."""
findings = []
# Only relevant for Go files (Python asyncio propagates automatically via contextvars)
if not filepath.endswith(".go"):
return []
for i, line in enumerate(lines):
if not re.search(r"^(\s*)go\s+(func|\w+)\s*\(", line):
continue
ctx = get_context_lines(lines, i, 15)
# Check if ctx is passed into the goroutine
if not re.search(r"ctx|context\.Context|trace\.SpanFromContext", ctx):
findings.append(Finding(
check_id="OT008",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message="Goroutine launched without passing context.Context — OTel trace context is lost in this goroutine.",
fix="go func(ctx context.Context) { ... }(ctx) — always thread ctx through goroutines.",
))
return findings
def check_ot009_console_exporter(filepath: str, lines: list) -> list:
"""OT009 — Console/stdout exporter in non-dev file."""
if is_dev_config_file(filepath):
return []
findings = []
for i, line in enumerate(lines):
if CONSOLE_EXPORTER_RE.search(line):
findings.append(Finding(
check_id="OT009",
severity="LOW",
location=f"{filepath}:{i + 1}",
message="ConsoleSpanExporter detected outside dev config. Console exporter floods logs with no backend value in production.",
fix="Replace with OTLPTraceExporter pointing to your Jaeger/Tempo/Honeycomb/Grafana Cloud endpoint.",
))
return findings
def check_ot010_high_cardinality_span_name(filepath: str, lines: list) -> list:
"""OT010 — High-cardinality span name (dynamic values in name string)."""
findings = []
for i, line in enumerate(lines):
if SPAN_START_WITH_TEMPLATE_RE.search(line):
findings.append(Finding(
check_id="OT010",
severity="LOW",
location=f"{filepath}:{i + 1}",
message=f"Span name uses dynamic value: '{line.strip()[:80]}'. High-cardinality names break trace aggregation in backends.",
fix="Use fixed template: tracer.startSpan('GET /users/{id}') and add userId as a span attribute: span.setAttribute('user.id', userId)",
))
return findings
# ─── Main Audit ───────────────────────────────────────────────────────────────
def audit(path: str) -> AuditResult:
result = AuditResult(scan_root=path)
files = collect_files(path)
all_contents = {}
for f in files:
try:
all_contents[str(f)] = f.read_text(errors="ignore")
except Exception:
pass
result.files_scanned = len(files)
# Global check
result.findings.extend(check_ot001_no_otel_sdk(all_contents))
# Per-file checks
for f in files:
content = all_contents.get(str(f), "")
lines = content.splitlines()
fp = str(f)
file_findings = []
file_findings.extend(check_ot002_handler_no_span(fp, lines))
file_findings.extend(check_ot003_db_outside_span(fp, lines))
file_findings.extend(check_ot004_exception_not_recorded(fp, lines))
file_findings.extend(check_ot005_no_service_name(fp, lines, content))
file_findings.extend(check_ot006_sdk_late_init(fp, lines))
file_findings.extend(check_ot007_span_end_no_status(fp, lines))
file_findings.extend(check_ot008_async_context_not_passed(fp, lines))
file_findings.extend(check_ot009_console_exporter(fp, lines))
file_findings.extend(check_ot010_high_cardinality_span_name(fp, lines))
if file_findings:
result.files_flagged += 1
result.findings.extend(file_findings)
return result
def format_report(result: AuditResult, ci_mode: bool = False) -> str:
out = []
out.append(f"\n{'='*60}")
out.append(f" OTel Instrumentation Audit — {result.scan_root}")
out.append(f" Files scanned: {result.files_scanned} | Files with issues: {result.files_flagged}")
out.append(f"{'='*60}")
if not result.findings:
out.append("✅ No OTel instrumentation gaps detected.")
return "\n".join(out)
for severity in ("HIGH", "MEDIUM", "LOW"):
sev_findings = [f for f in result.findings if f.severity == severity]
if sev_findings:
out.append(f"\n── {severity} ({len(sev_findings)}) {'─'*40}")
for finding in sev_findings:
out.append(str(finding))
out.append(f"\n{'─'*60}")
out.append(
f" Total: {len(result.findings)} findings | "
f"High: {result.high_count} Medium: {result.medium_count} Low: {result.low_count}"
)
if ci_mode and result.high_count > 0:
out.append("\n ❌ CI GATE FAILED — resolve HIGH findings before merging.")
return "\n".join(out)
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(
description="phy-otel-audit — OpenTelemetry Instrumentation Auditor",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python otel_audit.py ./src
python otel_audit.py src/handlers/orders.js
python otel_audit.py ./src --ci
python otel_audit.py ./src --only-severity HIGH
""",
)
parser.add_argument("path", help="Directory or file to audit")
parser.add_argument("--ci", action="store_true", help="Exit 1 on HIGH findings")
parser.add_argument(
"--only-severity",
choices=["HIGH", "MEDIUM", "LOW"],
help="Filter to this severity and above",
)
args = parser.parse_args()
result = audit(args.path)
sev_order = ["HIGH", "MEDIUM", "LOW"]
if args.only_severity:
cutoff = sev_order.index(args.only_severity)
result.findings = [f for f in result.findings if sev_order.index(f.severity) <= cutoff]
print(format_report(result, ci_mode=args.ci))
if args.ci and result.high_count > 0:
sys.exit(1)
if __name__ == "__main__":
main()
```
## CI Integration
```yaml
# GitHub Actions
- name: OTel Instrumentation Audit
run: python otel_audit.py ./src --ci
# Only block on HIGH
- name: OTel Audit (strict)
run: python otel_audit.py ./src --only-severity HIGH --ci
```
## False Positive Notes
- **OT002** — If you use auto-instrumentation exclusively (e.g., `@opentelemetry/auto-instrumentations-node`), framework-level spans are created automatically. OT002 fires because it can't see the auto-instrumentation wrapper. Suppress with `# phy-ignore: OT002` on the route line if auto-instrumentation is confirmed.
- **OT003** — DB calls in utility functions called from within spans will false-positive (the span context exists at runtime but isn't visible in the static 30-line window). Check actual trace output to confirm.
- **OT006** — Only checks Node.js entry files named `server`, `index`, `app`, `main`, `start`.
- **OT008** — Only checks Go goroutines. Python asyncio propagates context automatically via `contextvars`.
## Related Skills
- **phy-async-audit** — async error propagation (unhandled exceptions in spans)
- **phy-log-smell-auditor** — structured logging gaps that complement OTel spans
- **phy-rate-limit-audit** — rate limit signals visible in OTel metrics