Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "otel-as-code",
"version": "0.8.17",
"version": "0.8.18",
"description": "OpenTelemetry instrumentation and observability-as-code for any stack",
"author": {
"name": "radra.gwent",
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ jobs:
run: bash tests/hooks/go-instrument-wiring.test.sh
- name: Run PHP refusal test (detected, in scope, refused with a reason)
run: bash tests/hooks/php-refusal.test.sh
- name: Run zero-services guard contract (every command answers an empty scan the same way; #164)
run: bash tests/hooks/zero-services-guard.test.sh
- name: Run collector exporter-auth contract (--exporter-header; --force keeps exporter headers; #171)
run: bash tests/hooks/collector-exporter-auth.test.sh
- name: Run force-scope contract (--force overwrites only marker-carrying files; #169)
Expand Down Expand Up @@ -254,6 +256,14 @@ jobs:
! test -f fixtures/python-setuppy-app/Dockerfile
! grep -rq opentelemetry fixtures/python-setuppy-app

- name: Check infra-only-repo fixture (#164 — a repo with no application service)
run: |
test -f fixtures/infra-only-repo/infra/main.tf
test -f fixtures/infra-only-repo/scripts/tool/run.py
[ "$(wc -l < fixtures/infra-only-repo/scripts/tool/requirements.txt)" -eq 1 ]
! grep -qiE "flask|fastapi|django|uvicorn|serve_forever|app.run" fixtures/infra-only-repo/scripts/tool/run.py
! grep -rqi opentelemetry fixtures/infra-only-repo

- name: Check nextjs-brownfield-api-helper fixture (#169 — bootstrap vs hook vs API call site)
run: |
grep -q "new NodeSDK" fixtures/nextjs-brownfield-api-helper/src/telemetry.node.ts
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
> of the same shape, and every concrete line item that isn't done yet has a linked tracking issue —
> comment there rather than only in this file if you want to weigh in.

Current release: **0.8.17**. Node.js, Python, Java, .NET, Go, Ruby, and Rust (axum) instrumentation, Terraform
Current release: **0.8.18**. Node.js, Python, Java, .NET, Go, Ruby, and Rust (axum) instrumentation, Terraform
for Grafana, Datadog, New Relic, and Dash0, semconv pinned at 1.44.0. Since 0.2.0 the command set
has grown past generation: `/otel-uninstrument` rolls instrumentation back behind an ownership
marker, `/otel-remediate` proposes a deployment-config diff for services that have no application
Expand Down
12 changes: 12 additions & 0 deletions agents/terraform-gen.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ to the target directory. You run `terraform fmt` and `terraform validate` after
line shown under "Required header in every main.tf" so anyone reading the module later can
see the queries and SLO targets were not built from confirmed attributes.

## Refuse a module with no real service

A module is built from a service's identity (`name`, `version`, `namespace`): PromQL selectors,
the dashboard uid, folder and rule-group names, and alert labels all come from it. If
`context.services` is empty, or the service has no `name`, **write nothing and return the reason**
("no service identity in the context: run /otel-init on an application repository"). Never keep a
template placeholder such as `<SERVICE_NAME>` or `<SERVICE_VERSION>` as a variable default, and
never invent an identity (`unknown-service`, `0.0.0`, one inferred from unrelated files). The
result looks valid, but every selector matches nothing, so the dashboard is empty and an alert
with `no_data_state = "OK"` reports a healthy service that does not exist (#164). The dispatching
command applies the same rule first; this is the backstop for a stale cache or a direct call.

## kind parameter behavior

When `kind` is not `all`, generate only the specified resource type:
Expand Down
6 changes: 6 additions & 0 deletions commands/otel-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ Check `.claude/otel-context.json`. Apply the freshness rule from `/otel-init` St
what it returns — a refresh is a merge, never a replace (see the cache ownership contract in
`agents/repo-context-scanner.md`).

If the scan or the cache has **no services**, apply `/otel-init` Step 2's "Empty scan" rule
before anything else in this step, including the confirmation prompt below: print its message,
write nothing, exit 0. A module needs a real service identity to build selectors, folder and
alert names from; with none, `terraform-gen` would keep a template placeholder or invent one,
and the resulting dashboard and alerts match nothing (#164).

If `context.confirmedAt` is null (business attrs not confirmed):
- Print: "⚠ Business attributes have not been confirmed yet. Running /otel-business-attrs first
will improve the generated dashboard queries and SLO targets."
Expand Down
4 changes: 4 additions & 0 deletions commands/otel-collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ Check `.claude/otel-context.json`. Apply the freshness rule from `/otel-init` St
what it returns — a refresh is a merge, never a replace (see the cache ownership contract in
`agents/repo-context-scanner.md`).

If the scan or the cache has **no services**, apply `/otel-init` Step 2's "Empty scan" rule:
print its message, write nothing, exit 0. A Collector config describes what receives telemetry
from services; for a repo with none it would be a repo-root file that collects from nothing.

## Step 3: Determine output path

Write to: `otelcol-agent.yaml` (mode=agent) or `otelcol-gateway.yaml` (mode=gateway)
Expand Down
5 changes: 5 additions & 0 deletions commands/otel-evaluate.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ Check `.claude/otel-context.json`. Apply the freshness rule from `/otel-init` St
what it returns — a refresh is a merge, never a replace (see the cache ownership contract in
`agents/repo-context-scanner.md`).

If the scan or the cache has **no services**, apply `/otel-init` Step 2's "Empty scan" rule:
print its message, write nothing, exit 0. Do this before the check below: "every service reports
no OTel" is vacuously true over an empty list, and it would send the user to `/otel-instrument`,
which cannot do anything here either.

Then decide whether there is anything to audit. If every service reports
`existingOtel.hasTraces = false` AND `hasMetrics = false` AND `hasLogs = false`, **verify that
on disk before believing it.** These flags are only as fresh as the last scan, and a cache that
Expand Down
14 changes: 12 additions & 2 deletions commands/otel-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,20 @@ Wait for the JSON response. Never write a scanner response whose user-owned fiel
over a cache where they were not — if that happens the merge did not run; re-dispatch with
`priorContext` rather than writing the result.

If the subagent returns an empty `services` array:
**Empty scan (the shared rule).** If the subagent returns an empty `services` array — or a
cache you loaded already has `services: []`:
- Print: "No services detected. Is this an application repository? Check the service
detection rules in the otel-as-code docs."
- Exit.
- **Write nothing and exit 0.** In particular do NOT write the scan to `.claude/otel-context.json`:
the `.gitignore` update below lives in Step 4, which an empty scan never reaches, so a cache
written here is left as an untracked file in the user's repo.
- `/otel-evaluate`, `/otel-instrument`, `/otel-collector` and `/otel-backend` apply this same rule
right after they load or refresh the context (#164), and it overrides their "write what it
returns" instruction for a scan with no services. They reference it rather than restate it,
because a repo with no application service has nothing to audit, instrument, collect from, or
build a dashboard for, and each of them used to fail differently: a vacuous "no instrumentation
detected" that points back at a command that cannot help, an empty candidate list with no
reason, and a module built for a placeholder service.

## Step 3: Present detected service boundaries

Expand Down
5 changes: 5 additions & 0 deletions commands/otel-instrument.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ Check `.claude/otel-context.json`. Apply the freshness rule from `/otel-init` St
`Skill` tool and cannot read the guidance itself.
- Do NOT print the /otel-init success message; just run the scan silently.

If the scan or the cache has **no services**, apply `/otel-init` Step 2's "Empty scan" rule:
print its message, write nothing (not the cache either), exit 0. Step 2's "No candidates" branch
is for a repo that HAS services none of which can be instrumented, where each one carries a
reason; it must not be reached with an empty list.

## Step 2: Select the target SERVICE

Pick a service first; its language follows from it. Resolving a language alone settles nothing
Expand Down
9 changes: 9 additions & 0 deletions fixtures/infra-only-repo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# infra-only-repo

The shape from #164: an infrastructure repository with no application service. One OpenTofu
stack, docs, and a directory of one-shot Python scripts whose only manifest is a one-line
`requirements.txt`. No web framework, no entry point a process manager would start, no Dockerfile.

The scanner is expected to return `services: []` for it, and every command that needs a service
must then say what `/otel-init` says and write nothing, instead of dead-ending, printing an empty
candidate list, or building a module for a placeholder service.
12 changes: 12 additions & 0 deletions fixtures/infra-only-repo/infra/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}

resource "random_id" "suffix" {
byte_length = 4
}
1 change: 1 addition & 0 deletions fixtures/infra-only-repo/scripts/tool/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
markdown>=3.5
7 changes: 7 additions & 0 deletions fixtures/infra-only-repo/scripts/tool/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""One-shot maintenance script: render a markdown file to HTML. No server, no entry point."""
import sys

import markdown

if __name__ == "__main__":
print(markdown.markdown(open(sys.argv[1]).read()))
54 changes: 54 additions & 0 deletions tests/hooks/zero-services-guard.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# tests/hooks/zero-services-guard.test.sh
# Locks the #164 contract: a repo whose scan returns no services is answered the same way by every
# command that needs a service, and nothing is written. Before this, /otel-evaluate printed a
# vacuous "no instrumentation detected", /otel-instrument printed an empty candidate list,
# /otel-collector wrote a config for nothing, and /otel-backend built a module around a
# placeholder (<SERVICE_NAME>) or invented (unknown-service) identity whose alerts sat in
# no_data_state = "OK".
set -uo pipefail
cd "$(dirname "$0")/../.."
pass=0; fail=0
check() { if eval "$2"; then echo "PASS: $1"; pass=$((pass+1)); else echo "FAIL: $1"; fail=$((fail+1)); fi; }

INIT="commands/otel-init.md"
TFGEN="agents/terraform-gen.md"
FIX="fixtures/infra-only-repo"

check "otel-init defines the shared Empty scan rule: message, write nothing, exit 0" \
'grep -qF "**Empty scan (the shared rule).**" "$INIT" && grep -qF "**Write nothing and exit 0.**" "$INIT"'
check "the rule covers a cache that already has services: []" \
'grep -qF "cache you loaded already has \`services: []\`" "$INIT"'
check "the rule forbids writing the scan to the cache (the .gitignore update is not reached)" \
'grep -qF "do NOT write the scan to \`.claude/otel-context.json\`" "$INIT"'
check "the rule overrides the commands' 'write what it returns' instruction" \
'grep -qF "it overrides their \"write what it" "$INIT"'

for c in otel-evaluate otel-instrument otel-collector otel-backend; do
check "$c applies the Empty scan rule by reference, not by restating it" \
'grep -qF "apply \`/otel-init\` Step 2'"'"'s \"Empty scan\" rule" "commands/'"$c"'.md"'
done

check "evaluate applies it BEFORE the vacuous 'every service reports no OTel' check" \
'[ "$(grep -n "Empty scan" commands/otel-evaluate.md | head -1 | cut -d: -f1)" -lt "$(grep -n "Then decide whether there is anything to audit" commands/otel-evaluate.md | cut -d: -f1)" ]'
check "backend applies it BEFORE the unconfirmed-attributes prompt (so --yes cannot get past it)" \
'[ "$(grep -n "Empty scan" commands/otel-backend.md | head -1 | cut -d: -f1)" -lt "$(grep -n "If \`context.confirmedAt\` is null" commands/otel-backend.md | cut -d: -f1)" ]'
check "instrument says the No candidates branch is not for an empty list" \
'grep -qF "must not be reached with an empty list" commands/otel-instrument.md'

check "terraform-gen refuses a module with no real service" \
'grep -qF "## Refuse a module with no real service" "$TFGEN"'
check "terraform-gen forbids both the template placeholder and an invented identity" \
'grep -qF "Never keep a" "$TFGEN" && grep -qF "unknown-service" "$TFGEN" && grep -qF "<SERVICE_NAME>" "$TFGEN"'

# --- fixture: the issue's repro shape, structurally ---
check "fixture has no application manifest beyond a one-line requirements.txt" \
'[ "$(find "$FIX" -type f \( -name package.json -o -name pyproject.toml -o -name go.mod -o -name pom.xml -o -name Gemfile -o -name Cargo.toml -o -name "*.csproj" -o -name Dockerfile \) | wc -l)" -eq 0 ] && [ "$(wc -l < "$FIX/scripts/tool/requirements.txt")" -eq 1 ]'
check "fixture's script has no web framework and no server" \
'! grep -qiE "flask|fastapi|django|uvicorn|http.server|serve_forever|app.run" "$FIX/scripts/tool/run.py"'
check "fixture carries an infrastructure stack and no OTel" \
'test -f "$FIX/infra/main.tf" && ! grep -rqi opentelemetry "$FIX"'

echo ""
echo "Results: $pass passed, $fail failed"
[ "$fail" -eq 0 ]
Loading