Skip to content

Commit b0cfb0c

Browse files
jwaldripclaude
andcommitted
feat(telemetry): add OTEL reporting for AI-DLC workflow events
Reports structured log events to the same OTLP endpoint used by han. Uses curl with OTLP/JSON format, backgrounded to avoid blocking. Tracks: intent lifecycle, unit status, hat transitions, bolt iterations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c0349b7 commit b0cfb0c

File tree

3 files changed

+300
-0
lines changed

3 files changed

+300
-0
lines changed

plugin/hooks/inject-context.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ if [ -f "$HAIKU_LIB" ]; then
4949
source "$HAIKU_LIB"
5050
fi
5151

52+
# Source telemetry library (non-blocking, no-op when disabled)
53+
TELEMETRY_LIB="${CLAUDE_PLUGIN_ROOT}/lib/telemetry.sh"
54+
if [ -f "$TELEMETRY_LIB" ]; then
55+
# shellcheck source=/dev/null
56+
source "$TELEMETRY_LIB"
57+
aidlc_telemetry_init
58+
fi
59+
5260
# Detect project maturity (greenfield / early / established)
5361
PROJECT_MATURITY=""
5462
if type detect_project_maturity &>/dev/null; then
@@ -354,6 +362,13 @@ if [ "$NEEDS_ADVANCE" = "true" ] && [ "$SOURCE" != "compact" ]; then
354362
else
355363
han keep save iteration.json "$ITERATION_JSON" 2>/dev/null || true
356364
fi
365+
366+
# Emit telemetry for bolt iteration advance
367+
if type aidlc_log_event &>/dev/null; then
368+
_ADVANCE_INTENT_SLUG=$(echo "$ITERATION_JSON" | han parse json intentSlug -r --default "" 2>/dev/null || echo "")
369+
_ADVANCE_UNIT_SLUG=$(echo "$ITERATION_JSON" | han parse json targetUnit -r --default "" 2>/dev/null || echo "")
370+
aidlc_record_bolt_iteration "$_ADVANCE_INTENT_SLUG" "$_ADVANCE_UNIT_SLUG" "$NEW_ITER" "advanced"
371+
fi
357372
fi
358373

359374
# Parse iteration state using han parse (no jq needed)

plugin/lib/dag.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,33 @@ update_unit_status() {
478478
;;
479479
esac
480480

481+
# Capture old status for telemetry
482+
local old_status
483+
old_status=$(parse_unit_status "$unit_file")
484+
481485
# Update status in frontmatter using han parse yaml-set
482486
han parse yaml-set status "$new_status" < "$unit_file" > "$unit_file.tmp" && mv "$unit_file.tmp" "$unit_file"
487+
488+
# Emit telemetry for unit status change (non-blocking)
489+
if [ -z "${_AIDLC_TELEMETRY_INIT:-}" ]; then
490+
local telemetry_lib="${SCRIPT_DIR}/telemetry.sh"
491+
if [ -f "$telemetry_lib" ]; then
492+
# shellcheck source=telemetry.sh
493+
source "$telemetry_lib"
494+
aidlc_telemetry_init
495+
fi
496+
fi
497+
if type aidlc_record_unit_status_change &>/dev/null; then
498+
# Extract intent slug and unit slug from the path
499+
# Path pattern: .ai-dlc/<intent_slug>/unit-NN-<unit_slug>.md
500+
local unit_basename
501+
unit_basename=$(basename "$unit_file" .md)
502+
local intent_slug=""
503+
if [[ "$real_path" =~ /\.ai-dlc/([^/]+)/ ]]; then
504+
intent_slug="${BASH_REMATCH[1]}"
505+
fi
506+
aidlc_record_unit_status_change "$intent_slug" "$unit_basename" "$old_status" "$new_status"
507+
fi
483508
}
484509

485510
# Get DAG summary counts

plugin/lib/telemetry.sh

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
#!/bin/bash
2+
# telemetry.sh - OTEL telemetry library for AI-DLC
3+
#
4+
# Sends structured log events to an OTLP/JSON endpoint via curl.
5+
# Reports as service.name=ai-dlc alongside han's service.name=han.
6+
#
7+
# Environment variables:
8+
# CLAUDE_CODE_ENABLE_TELEMETRY=1 - Master switch (must be "1" to enable)
9+
# OTEL_EXPORTER_OTLP_ENDPOINT - Collector endpoint (default: http://localhost:4317)
10+
# OTEL_EXPORTER_OTLP_HEADERS - Auth headers (format: key1=value1,key2=value2)
11+
# OTEL_RESOURCE_ATTRIBUTES - Custom resource attributes (key1=value1,key2=value2)
12+
#
13+
# Usage:
14+
# source telemetry.sh
15+
# aidlc_telemetry_init
16+
# aidlc_log_event "ai_dlc.intent.created" "intent_slug=my-feature" "strategy=unit"
17+
18+
# Guard against double-sourcing
19+
if [ -n "${_AIDLC_TELEMETRY_SOURCED:-}" ]; then
20+
return 0 2>/dev/null || exit 0
21+
fi
22+
_AIDLC_TELEMETRY_SOURCED=1
23+
24+
# Internal state
25+
_AIDLC_TELEMETRY_INIT=""
26+
_AIDLC_TELEMETRY_ENABLED=""
27+
_AIDLC_TELEMETRY_ENDPOINT=""
28+
_AIDLC_TELEMETRY_VERSION=""
29+
_AIDLC_TELEMETRY_CURL_HEADERS=""
30+
31+
# Initialize telemetry (call once per script)
32+
# Reads env vars, detects plugin version, builds curl header flags.
33+
# If CLAUDE_CODE_ENABLE_TELEMETRY != "1", all functions become no-ops.
34+
aidlc_telemetry_init() {
35+
_AIDLC_TELEMETRY_INIT=1
36+
37+
# Master switch
38+
if [ "${CLAUDE_CODE_ENABLE_TELEMETRY:-}" != "1" ]; then
39+
_AIDLC_TELEMETRY_ENABLED=0
40+
return 0
41+
fi
42+
43+
_AIDLC_TELEMETRY_ENABLED=1
44+
45+
# Endpoint (default matches han's default)
46+
_AIDLC_TELEMETRY_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://localhost:4317}"
47+
# Strip trailing slash
48+
_AIDLC_TELEMETRY_ENDPOINT="${_AIDLC_TELEMETRY_ENDPOINT%/}"
49+
50+
# Read plugin version from plugin.json
51+
_AIDLC_TELEMETRY_VERSION=""
52+
local plugin_json=""
53+
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -f "${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json" ]; then
54+
plugin_json="${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json"
55+
else
56+
# Fallback: resolve relative to this script
57+
local script_dir
58+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
59+
local candidate="${script_dir}/../.claude-plugin/plugin.json"
60+
if [ -f "$candidate" ]; then
61+
plugin_json="$candidate"
62+
fi
63+
fi
64+
65+
if [ -n "$plugin_json" ]; then
66+
# Extract version with pure bash (avoid jq dependency)
67+
local line
68+
while IFS= read -r line; do
69+
if [[ "$line" =~ \"version\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
70+
_AIDLC_TELEMETRY_VERSION="${BASH_REMATCH[1]}"
71+
break
72+
fi
73+
done < "$plugin_json"
74+
fi
75+
: "${_AIDLC_TELEMETRY_VERSION:=unknown}"
76+
77+
# Parse OTEL_EXPORTER_OTLP_HEADERS into curl -H flags
78+
_AIDLC_TELEMETRY_CURL_HEADERS=""
79+
if [ -n "${OTEL_EXPORTER_OTLP_HEADERS:-}" ]; then
80+
local IFS=','
81+
for pair in $OTEL_EXPORTER_OTLP_HEADERS; do
82+
local key="${pair%%=*}"
83+
local value="${pair#*=}"
84+
if [ -n "$key" ] && [ -n "$value" ]; then
85+
_AIDLC_TELEMETRY_CURL_HEADERS="${_AIDLC_TELEMETRY_CURL_HEADERS} -H ${key}:${value}"
86+
fi
87+
done
88+
unset IFS
89+
fi
90+
}
91+
92+
# Get epoch time in nanoseconds (best effort)
93+
# Falls back to seconds * 1e9 if no nanosecond source available
94+
_aidlc_epoch_nanos() {
95+
# Try GNU date with nanoseconds
96+
if date +%s%N 2>/dev/null | grep -qE '^[0-9]{19}'; then
97+
date +%s%N
98+
return
99+
fi
100+
# macOS / BSD fallback: seconds * 1_000_000_000
101+
local secs
102+
secs=$(date +%s)
103+
echo "${secs}000000000"
104+
}
105+
106+
# Build OTLP resource attributes JSON array
107+
_aidlc_resource_attributes() {
108+
local attrs=""
109+
attrs="${attrs}{\"key\":\"service.name\",\"value\":{\"stringValue\":\"ai-dlc\"}}"
110+
attrs="${attrs},{\"key\":\"service.version\",\"value\":{\"stringValue\":\"${_AIDLC_TELEMETRY_VERSION}\"}}"
111+
112+
# Append OTEL_RESOURCE_ATTRIBUTES if set
113+
if [ -n "${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then
114+
local IFS=','
115+
for pair in $OTEL_RESOURCE_ATTRIBUTES; do
116+
local key="${pair%%=*}"
117+
local value="${pair#*=}"
118+
if [ -n "$key" ] && [ -n "$value" ]; then
119+
attrs="${attrs},{\"key\":\"${key}\",\"value\":{\"stringValue\":\"${value}\"}}"
120+
fi
121+
done
122+
unset IFS
123+
fi
124+
125+
echo "[${attrs}]"
126+
}
127+
128+
# Core function: send a structured log event via OTLP/JSON
129+
# Usage: aidlc_log_event <event_name> [key=value ...]
130+
# All calls are backgrounded and fail silently.
131+
aidlc_log_event() {
132+
# No-op if not initialized or not enabled
133+
if [ "${_AIDLC_TELEMETRY_ENABLED:-0}" != "1" ]; then
134+
return 0
135+
fi
136+
137+
local event_name="${1:?aidlc_log_event requires an event name}"
138+
shift
139+
140+
local time_nanos
141+
time_nanos=$(_aidlc_epoch_nanos)
142+
143+
# Build log record attributes
144+
local log_attrs=""
145+
log_attrs="{\"key\":\"event.name\",\"value\":{\"stringValue\":\"${event_name}\"}}"
146+
147+
for pair in "$@"; do
148+
local key="${pair%%=*}"
149+
local value="${pair#*=}"
150+
if [ -n "$key" ]; then
151+
# Escape double quotes in value
152+
value="${value//\\/\\\\}"
153+
value="${value//\"/\\\"}"
154+
log_attrs="${log_attrs},{\"key\":\"${key}\",\"value\":{\"stringValue\":\"${value}\"}}"
155+
fi
156+
done
157+
158+
local resource_attrs
159+
resource_attrs=$(_aidlc_resource_attributes)
160+
161+
local payload
162+
payload=$(cat <<PAYLOAD_EOF
163+
{"resourceLogs":[{"resource":{"attributes":${resource_attrs}},"scopeLogs":[{"scope":{"name":"ai-dlc"},"logRecords":[{"timeUnixNano":"${time_nanos}","severityNumber":9,"severityText":"INFO","body":{"stringValue":"${event_name}"},"attributes":[${log_attrs}]}]}]}]}
164+
PAYLOAD_EOF
165+
)
166+
167+
# Send via curl in the background, fail silently
168+
# shellcheck disable=SC2086
169+
(curl -s -S \
170+
--connect-timeout 2 \
171+
--max-time 5 \
172+
-X POST \
173+
-H "Content-Type: application/json" \
174+
${_AIDLC_TELEMETRY_CURL_HEADERS} \
175+
-d "$payload" \
176+
"${_AIDLC_TELEMETRY_ENDPOINT}/v1/logs" \
177+
>/dev/null 2>&1 || true) &
178+
}
179+
180+
# ============================================================================
181+
# Convenience functions for common AI-DLC events
182+
# ============================================================================
183+
184+
# Intent created
185+
# Usage: aidlc_record_intent_created <slug> <strategy>
186+
aidlc_record_intent_created() {
187+
local slug="${1:-}" strategy="${2:-}"
188+
aidlc_log_event "ai_dlc.intent.created" \
189+
"intent_slug=${slug}" \
190+
"strategy=${strategy}"
191+
}
192+
193+
# Intent completed
194+
# Usage: aidlc_record_intent_completed <slug> <unit_count>
195+
aidlc_record_intent_completed() {
196+
local slug="${1:-}" unit_count="${2:-}"
197+
aidlc_log_event "ai_dlc.intent.completed" \
198+
"intent_slug=${slug}" \
199+
"unit_count=${unit_count}"
200+
}
201+
202+
# Unit status change
203+
# Usage: aidlc_record_unit_status_change <intent_slug> <unit_slug> <old_status> <new_status>
204+
aidlc_record_unit_status_change() {
205+
local intent_slug="${1:-}" unit_slug="${2:-}" old_status="${3:-}" new_status="${4:-}"
206+
aidlc_log_event "ai_dlc.unit.status_change" \
207+
"intent_slug=${intent_slug}" \
208+
"unit_slug=${unit_slug}" \
209+
"old_status=${old_status}" \
210+
"new_status=${new_status}"
211+
}
212+
213+
# Hat transition
214+
# Usage: aidlc_record_hat_transition <intent_slug> <from_hat> <to_hat>
215+
aidlc_record_hat_transition() {
216+
local intent_slug="${1:-}" from_hat="${2:-}" to_hat="${3:-}"
217+
aidlc_log_event "ai_dlc.hat.transition" \
218+
"intent_slug=${intent_slug}" \
219+
"from_hat=${from_hat}" \
220+
"to_hat=${to_hat}"
221+
}
222+
223+
# Bolt iteration
224+
# Usage: aidlc_record_bolt_iteration <intent_slug> <unit_slug> <bolt_number> <outcome>
225+
aidlc_record_bolt_iteration() {
226+
local intent_slug="${1:-}" unit_slug="${2:-}" bolt_number="${3:-}" outcome="${4:-}"
227+
aidlc_log_event "ai_dlc.bolt.iteration" \
228+
"intent_slug=${intent_slug}" \
229+
"unit_slug=${unit_slug}" \
230+
"bolt_number=${bolt_number}" \
231+
"outcome=${outcome}"
232+
}
233+
234+
# Elaboration complete
235+
# Usage: aidlc_record_elaboration_complete <intent_slug> <unit_count> <has_wireframes>
236+
aidlc_record_elaboration_complete() {
237+
local intent_slug="${1:-}" unit_count="${2:-}" has_wireframes="${3:-}"
238+
aidlc_log_event "ai_dlc.elaboration.complete" \
239+
"intent_slug=${intent_slug}" \
240+
"unit_count=${unit_count}" \
241+
"has_wireframes=${has_wireframes}"
242+
}
243+
244+
# Followup created
245+
# Usage: aidlc_record_followup_created <intent_slug> <unit_slug>
246+
aidlc_record_followup_created() {
247+
local intent_slug="${1:-}" unit_slug="${2:-}"
248+
aidlc_log_event "ai_dlc.followup.created" \
249+
"intent_slug=${intent_slug}" \
250+
"unit_slug=${unit_slug}"
251+
}
252+
253+
# Cleanup run
254+
# Usage: aidlc_record_cleanup <orphaned_count> <merged_count>
255+
aidlc_record_cleanup() {
256+
local orphaned_count="${1:-}" merged_count="${2:-}"
257+
aidlc_log_event "ai_dlc.cleanup.run" \
258+
"orphaned_count=${orphaned_count}" \
259+
"merged_count=${merged_count}"
260+
}

0 commit comments

Comments
 (0)