claude_memory 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/memory.sqlite3 +0 -0
- data/.claude/rules/claude_memory.generated.md +42 -64
- data/.claude/skills/release/SKILL.md +44 -6
- data/.claude/skills/study-repo/SKILL.md +15 -0
- data/.claude-plugin/commands/audit-memory.md +68 -0
- data/.claude-plugin/marketplace.json +1 -1
- data/.claude-plugin/plugin.json +1 -1
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +9 -2
- data/README.md +29 -1
- data/db/migrations/018_add_otel_telemetry.rb +81 -0
- data/docs/1_0_punchlist.md +318 -66
- data/docs/api_stability.md +341 -0
- data/docs/audit_runbook.md +209 -0
- data/docs/claude_monitoring.md +956 -0
- data/docs/improvements.md +148 -9
- data/docs/influence/ai-memory-systems-2026.md +403 -0
- data/docs/memory_audit_2026-05-21.md +303 -0
- data/docs/plugin.md +1 -1
- data/lib/claude_memory/audit/checks.rb +239 -0
- data/lib/claude_memory/audit/finding.rb +33 -0
- data/lib/claude_memory/audit/runner.rb +73 -0
- data/lib/claude_memory/commands/audit_command.rb +117 -0
- data/lib/claude_memory/commands/dashboard_command.rb +2 -1
- data/lib/claude_memory/commands/import_auto_memory_command.rb +180 -0
- data/lib/claude_memory/commands/otel_command.rb +240 -0
- data/lib/claude_memory/commands/registry.rb +4 -1
- data/lib/claude_memory/configuration.rb +60 -0
- data/lib/claude_memory/core/fact_query_builder.rb +1 -0
- data/lib/claude_memory/dashboard/api.rb +8 -0
- data/lib/claude_memory/dashboard/index.html +140 -1
- data/lib/claude_memory/dashboard/prompt_journey.rb +48 -0
- data/lib/claude_memory/dashboard/server.rb +86 -0
- data/lib/claude_memory/dashboard/telemetry.rb +156 -0
- data/lib/claude_memory/deprecations.rb +106 -0
- data/lib/claude_memory/distill/reference_material_detector.rb +37 -4
- data/lib/claude_memory/hook/auto_memory_mirror.rb +7 -3
- data/lib/claude_memory/hook/context_injector.rb +11 -2
- data/lib/claude_memory/mcp/tool_definitions.rb +3 -3
- data/lib/claude_memory/otel/attributes.rb +118 -0
- data/lib/claude_memory/otel/constants.rb +32 -0
- data/lib/claude_memory/otel/ingestor.rb +54 -0
- data/lib/claude_memory/otel/otlp_json_envelope.rb +254 -0
- data/lib/claude_memory/otel/prompt_scope.rb +108 -0
- data/lib/claude_memory/otel/settings_writer.rb +122 -0
- data/lib/claude_memory/otel/status.rb +58 -0
- data/lib/claude_memory/recall/staleness_annotator.rb +73 -0
- data/lib/claude_memory/resolve/predicate_policy.rb +17 -1
- data/lib/claude_memory/resolve/resolver.rb +30 -3
- data/lib/claude_memory/shortcuts.rb +61 -18
- data/lib/claude_memory/store/prompt_journey_query.rb +87 -0
- data/lib/claude_memory/store/schema_manager.rb +1 -1
- data/lib/claude_memory/store/sqlite_store.rb +136 -0
- data/lib/claude_memory/sweep/maintenance.rb +31 -1
- data/lib/claude_memory/sweep/sweeper.rb +6 -0
- data/lib/claude_memory/version.rb +1 -1
- data/lib/claude_memory.rb +18 -0
- metadata +26 -1
|
@@ -32,11 +32,12 @@ module ClaudeMemory
|
|
|
32
32
|
# so a bare ID without scope is ambiguous.
|
|
33
33
|
attr_reader :emitted_fact_ids, :emitted_subjects, :emitted_facts_by_scope
|
|
34
34
|
|
|
35
|
-
def initialize(manager, source: nil, auto_memory_mirror: nil)
|
|
35
|
+
def initialize(manager, source: nil, auto_memory_mirror: nil, stale_threshold_days: nil)
|
|
36
36
|
@manager = manager
|
|
37
37
|
@source = source
|
|
38
38
|
@recall = Recall.new(manager)
|
|
39
39
|
@auto_memory_mirror = auto_memory_mirror
|
|
40
|
+
@stale_threshold_days = stale_threshold_days
|
|
40
41
|
@emitted_fact_ids = []
|
|
41
42
|
@emitted_subjects = []
|
|
42
43
|
@emitted_facts_by_scope = Hash.new { |h, k| h[k] = [] }
|
|
@@ -108,11 +109,19 @@ module ClaudeMemory
|
|
|
108
109
|
predicate = fact[:predicate]
|
|
109
110
|
object = fact[:object_literal]
|
|
110
111
|
|
|
111
|
-
if subject && predicate && object
|
|
112
|
+
line = if subject && predicate && object
|
|
112
113
|
"#{subject}.#{predicate} = #{object}"
|
|
113
114
|
elsif object
|
|
114
115
|
object.to_s
|
|
115
116
|
end
|
|
117
|
+
return nil unless line
|
|
118
|
+
|
|
119
|
+
marker = Recall::StalenessAnnotator.marker_for(fact, threshold_days: stale_threshold_days)
|
|
120
|
+
marker ? "#{line} #{marker}" : line
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stale_threshold_days
|
|
124
|
+
@stale_threshold_days ||= Configuration.new.injection_stale_days
|
|
116
125
|
end
|
|
117
126
|
|
|
118
127
|
def fetch_undistilled(limit)
|
|
@@ -284,7 +284,7 @@ module ClaudeMemory
|
|
|
284
284
|
},
|
|
285
285
|
{
|
|
286
286
|
name: "memory.decisions",
|
|
287
|
-
description: "List
|
|
287
|
+
description: "List facts with predicate=decision from both project and global memory (project first). Returns only `decision`-predicate facts — does not include `uses_database`, `uses_framework`, etc.",
|
|
288
288
|
inputSchema: {
|
|
289
289
|
type: "object",
|
|
290
290
|
properties: {
|
|
@@ -295,7 +295,7 @@ module ClaudeMemory
|
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
name: "memory.conventions",
|
|
298
|
-
description: "List
|
|
298
|
+
description: "List facts with predicate=convention from both project and global memory (project first). Use this to see project coding conventions alongside user-wide style preferences.",
|
|
299
299
|
inputSchema: {
|
|
300
300
|
type: "object",
|
|
301
301
|
properties: {
|
|
@@ -306,7 +306,7 @@ module ClaudeMemory
|
|
|
306
306
|
},
|
|
307
307
|
{
|
|
308
308
|
name: "memory.architecture",
|
|
309
|
-
description: "List
|
|
309
|
+
description: "List architecture facts and stack-shaping constraints (predicates: architecture, uses_database, uses_framework, uses_language, deployment_platform, auth_method) from both project and global memory.",
|
|
310
310
|
inputSchema: {
|
|
311
311
|
type: "object",
|
|
312
312
|
properties: {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Value object wrapping an OTel attribute hash (already flattened from
|
|
8
|
+
# the OTLP KeyValue representation by OtlpJsonEnvelope). Hides the
|
|
9
|
+
# primitive Hash from callers so panels and ingestors don't reach into
|
|
10
|
+
# raw JSON keys.
|
|
11
|
+
#
|
|
12
|
+
# All accessors return nil when the underlying attribute is missing.
|
|
13
|
+
# Frozen on construction — pass a fresh hash if you need to mutate.
|
|
14
|
+
class Attributes
|
|
15
|
+
# OTel keys we treat as "captured prompt content". Used by
|
|
16
|
+
# #contains_prompt_content? to flag privacy concerns regardless of
|
|
17
|
+
# which content flag was flipped (OTEL_LOG_USER_PROMPTS,
|
|
18
|
+
# OTEL_LOG_TOOL_CONTENT, OTEL_LOG_RAW_API_BODIES).
|
|
19
|
+
PROMPT_CONTENT_KEYS = %w[
|
|
20
|
+
prompt
|
|
21
|
+
body
|
|
22
|
+
tool_input
|
|
23
|
+
tool.output
|
|
24
|
+
full_command
|
|
25
|
+
user_prompt
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# @param hash [Hash] flattened attributes (string keys)
|
|
29
|
+
def initialize(hash)
|
|
30
|
+
@hash = (hash || {}).dup.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parse a JSON string into Attributes. Returns Attributes wrapping
|
|
35
|
+
# an empty hash for nil, blank, or unparseable input — matches the
|
|
36
|
+
# tolerance the existing dashboard panels expect.
|
|
37
|
+
#
|
|
38
|
+
# @param json_string [String, nil]
|
|
39
|
+
# @return [Attributes]
|
|
40
|
+
def self.from_json(json_string)
|
|
41
|
+
return new({}) if json_string.nil? || json_string.empty?
|
|
42
|
+
new(JSON.parse(json_string))
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
new({})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@hash
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_json(*args)
|
|
52
|
+
@hash.to_json(*args)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@hash[key.to_s]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Claude Code attaches `prompt.id` to events that should be UNION'd into
|
|
60
|
+
# the prompt journey. See docs/claude_monitoring.md.
|
|
61
|
+
def prompt_id
|
|
62
|
+
@hash["prompt.id"] || @hash["prompt_id"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def session_id
|
|
66
|
+
@hash["session.id"] || @hash["session_id"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GenAI semconv canonical key + Claude Code's `model` alias.
|
|
70
|
+
def model
|
|
71
|
+
@hash["gen_ai.request.model"] || @hash["model"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tool_name
|
|
75
|
+
@hash["tool_name"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Cost counter values arrive as the metric value, not as an attribute.
|
|
79
|
+
# The `type` attribute on token.usage tells us input/output/cacheRead/
|
|
80
|
+
# cacheCreation; this is only useful for token rows. Returns nil when
|
|
81
|
+
# missing so callers can guard with #compact.
|
|
82
|
+
def token_type
|
|
83
|
+
@hash["type"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Token count carried on a token.usage data point.
|
|
87
|
+
# @param row [Hash] otel_metrics row
|
|
88
|
+
def self.token_count(row)
|
|
89
|
+
(row[:value_int] || row[:value_float] || 0).to_i
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Tool execution duration in ms when the event is a tool_result.
|
|
93
|
+
# @return [Integer, nil]
|
|
94
|
+
def duration_ms
|
|
95
|
+
value = @hash["duration_ms"]
|
|
96
|
+
value&.to_i
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def query_source
|
|
100
|
+
@hash["query_source"]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def speed
|
|
104
|
+
@hash["speed"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# True when any attribute carries actual prompt or body content. Used
|
|
108
|
+
# by panels to render a one-line privacy notice without auto-flipping
|
|
109
|
+
# OTEL_LOG_USER_PROMPTS.
|
|
110
|
+
def contains_prompt_content?
|
|
111
|
+
PROMPT_CONTENT_KEYS.any? do |key|
|
|
112
|
+
value = @hash[key]
|
|
113
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module OTel
|
|
5
|
+
# Canonical metric value types stored in otel_metrics.value_type.
|
|
6
|
+
module ValueType
|
|
7
|
+
INT = "int"
|
|
8
|
+
DOUBLE = "double"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Canonical event names emitted by Claude Code's OTel instrumentation.
|
|
12
|
+
# Used by panels for filtering and by the parser when stripping the
|
|
13
|
+
# `claude_code.` prefix off `event.name` attributes.
|
|
14
|
+
module EventName
|
|
15
|
+
USER_PROMPT = "user_prompt"
|
|
16
|
+
TOOL_RESULT = "tool_result"
|
|
17
|
+
API_REQUEST = "api_request"
|
|
18
|
+
API_ERROR = "api_error"
|
|
19
|
+
API_REQUEST_BODY = "api_request_body"
|
|
20
|
+
API_RESPONSE_BODY = "api_response_body"
|
|
21
|
+
|
|
22
|
+
API_PAIR = [API_REQUEST, API_ERROR].freeze
|
|
23
|
+
PROMPT_BODY_FAMILY = [USER_PROMPT, TOOL_RESULT, API_REQUEST_BODY, API_RESPONSE_BODY].freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Canonical metric names that the dashboard queries.
|
|
27
|
+
module MetricName
|
|
28
|
+
TOKEN_USAGE = "claude_code.token.usage"
|
|
29
|
+
COST_USAGE = "claude_code.cost.usage"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../core/result"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Imperative shell for OTel ingestion. Takes the parsed-row hashes
|
|
8
|
+
# produced by OtlpJsonEnvelope and writes them in a single batched
|
|
9
|
+
# transaction. Returns Core::Result so the HTTP server can map outcome
|
|
10
|
+
# to status code without rescue clauses.
|
|
11
|
+
#
|
|
12
|
+
# The ingestor accepts a `:metrics`, `:events`, or `:traces` payload —
|
|
13
|
+
# one kind per call, matching how OTLP/HTTP separates the three
|
|
14
|
+
# endpoints. Wrap each batch in transaction_with_retry so a partial
|
|
15
|
+
# failure mid-insert leaves zero rows behind.
|
|
16
|
+
class Ingestor
|
|
17
|
+
def initialize(store)
|
|
18
|
+
@store = store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param payload [Hash] one of {metrics: [...]}, {events: [...]},
|
|
22
|
+
# {traces: [...]}. Other keys are ignored.
|
|
23
|
+
# @return [Core::Result] success carries inserted-count Hash;
|
|
24
|
+
# failure carries an error message
|
|
25
|
+
def ingest(payload)
|
|
26
|
+
return Core::Result.failure("payload must be a Hash") unless payload.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
counts = {metrics: 0, events: 0, traces: 0}
|
|
29
|
+
@store.transaction_with_retry do
|
|
30
|
+
counts[:metrics] = insert_metrics(payload[:metrics] || payload["metrics"])
|
|
31
|
+
counts[:events] = insert_events(payload[:events] || payload["events"])
|
|
32
|
+
counts[:traces] = insert_traces(payload[:traces] || payload["traces"])
|
|
33
|
+
end
|
|
34
|
+
Core::Result.success(counts)
|
|
35
|
+
rescue Sequel::DatabaseError, Extralite::Error, ArgumentError, KeyError => e
|
|
36
|
+
Core::Result.failure(e.message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def insert_metrics(rows)
|
|
42
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_metrics(rows) : 0
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def insert_events(rows)
|
|
46
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_events(rows) : 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def insert_traces(rows)
|
|
50
|
+
rows.is_a?(Array) ? @store.bulk_insert_otel_traces(rows) : 0
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module ClaudeMemory
|
|
6
|
+
module OTel
|
|
7
|
+
# Pure functional core for OTLP/HTTP/JSON payloads. Walks the canonical
|
|
8
|
+
# OTLP envelope shapes (resourceMetrics → scopeMetrics → metrics →
|
|
9
|
+
# dataPoints; resourceLogs → scopeLogs → logRecords; resourceSpans →
|
|
10
|
+
# scopeSpans → spans), flattens KeyValue attribute arrays into Ruby
|
|
11
|
+
# hashes, and returns plain row hashes ready to insert.
|
|
12
|
+
#
|
|
13
|
+
# No Time.now, no ENV reads, no DB. Pass a clock object that responds
|
|
14
|
+
# to `now` (or just `Time`) for fallback timestamps when the payload
|
|
15
|
+
# omits one. Required keys raise via Hash#fetch; optional containers
|
|
16
|
+
# default to empty arrays.
|
|
17
|
+
#
|
|
18
|
+
# All public methods return Arrays of Hashes whose keys match the
|
|
19
|
+
# SQLiteStore.insert_otel_* helpers exactly.
|
|
20
|
+
module OtlpJsonEnvelope
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param payload [Hash] parsed OTLP MetricsServiceRequest JSON
|
|
24
|
+
# @param clock [#now] used for fallback when timeUnixNano is missing
|
|
25
|
+
# @return [Array<Hash>] rows for SQLiteStore#insert_otel_metric
|
|
26
|
+
def parse_metrics(payload, clock: Time)
|
|
27
|
+
rows = []
|
|
28
|
+
Array(payload["resourceMetrics"]).each do |resource_metric|
|
|
29
|
+
resource = flatten_attributes(dig_attributes(resource_metric["resource"]))
|
|
30
|
+
Array(resource_metric["scopeMetrics"]).each do |scope_metric|
|
|
31
|
+
Array(scope_metric["metrics"]).each do |metric|
|
|
32
|
+
metric_name = metric.fetch("name")
|
|
33
|
+
unit = metric["unit"]
|
|
34
|
+
data_points = collect_data_points(metric)
|
|
35
|
+
data_points.each do |point|
|
|
36
|
+
value_type, value_int, value_float = decode_metric_value(point)
|
|
37
|
+
next if value_type.nil?
|
|
38
|
+
|
|
39
|
+
rows << {
|
|
40
|
+
name: metric_name,
|
|
41
|
+
value_type: value_type,
|
|
42
|
+
value_int: value_int,
|
|
43
|
+
value_float: value_float,
|
|
44
|
+
unit: unit,
|
|
45
|
+
attributes: flatten_attributes(point["attributes"]),
|
|
46
|
+
resource: resource,
|
|
47
|
+
recorded_at: timestamp_from_point(point, clock)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
rows
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param payload [Hash] parsed OTLP LogsServiceRequest JSON
|
|
57
|
+
# @param clock [#now]
|
|
58
|
+
# @return [Array<Hash>] rows for SQLiteStore#insert_otel_event
|
|
59
|
+
def parse_logs(payload, clock: Time)
|
|
60
|
+
rows = []
|
|
61
|
+
Array(payload["resourceLogs"]).each do |resource_log|
|
|
62
|
+
resource = flatten_attributes(dig_attributes(resource_log["resource"]))
|
|
63
|
+
Array(resource_log["scopeLogs"]).each do |scope_log|
|
|
64
|
+
Array(scope_log["logRecords"]).each do |record|
|
|
65
|
+
attributes = flatten_attributes(record["attributes"])
|
|
66
|
+
rows << {
|
|
67
|
+
event_name: event_name_for(record, attributes),
|
|
68
|
+
occurred_at: timestamp_from_record(record, clock),
|
|
69
|
+
session_id: attributes["session.id"],
|
|
70
|
+
prompt_id: attributes["prompt.id"],
|
|
71
|
+
attributes: attributes,
|
|
72
|
+
resource: resource
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
rows
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param payload [Hash] parsed OTLP TracesServiceRequest JSON
|
|
81
|
+
# @param clock [#now]
|
|
82
|
+
# @return [Array<Hash>] rows for SQLiteStore#insert_otel_trace_span
|
|
83
|
+
def parse_traces(payload, clock: Time)
|
|
84
|
+
rows = []
|
|
85
|
+
Array(payload["resourceSpans"]).each do |resource_span|
|
|
86
|
+
resource = flatten_attributes(dig_attributes(resource_span["resource"]))
|
|
87
|
+
Array(resource_span["scopeSpans"]).each do |scope_span|
|
|
88
|
+
Array(scope_span["spans"]).each do |span|
|
|
89
|
+
attributes = flatten_attributes(span["attributes"])
|
|
90
|
+
start_nano = parse_unix_nano(span["startTimeUnixNano"])
|
|
91
|
+
end_nano = parse_unix_nano(span["endTimeUnixNano"])
|
|
92
|
+
rows << {
|
|
93
|
+
trace_id: span.fetch("traceId"),
|
|
94
|
+
span_id: span.fetch("spanId"),
|
|
95
|
+
parent_span_id: span["parentSpanId"],
|
|
96
|
+
name: span.fetch("name"),
|
|
97
|
+
session_id: attributes["session.id"],
|
|
98
|
+
prompt_id: attributes["prompt.id"],
|
|
99
|
+
start_unix_nano: start_nano,
|
|
100
|
+
end_unix_nano: end_nano,
|
|
101
|
+
duration_ms: duration_ms_from(start_nano, end_nano),
|
|
102
|
+
status_code: span.dig("status", "code")&.to_s,
|
|
103
|
+
attributes: attributes,
|
|
104
|
+
resource: resource,
|
|
105
|
+
recorded_at: timestamp_from_unix_nano(start_nano, clock)
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
rows
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Flatten OTel KeyValue array (`[{key:, value: {stringValue: ...}}, ...]`)
|
|
114
|
+
# into a plain Hash.
|
|
115
|
+
def flatten_attributes(kv_array)
|
|
116
|
+
result = {}
|
|
117
|
+
Array(kv_array).each do |kv|
|
|
118
|
+
next unless kv.is_a?(Hash)
|
|
119
|
+
key = kv["key"]
|
|
120
|
+
next if key.nil? || key.empty?
|
|
121
|
+
result[key] = decode_any_value(kv["value"])
|
|
122
|
+
end
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def decode_any_value(value)
|
|
127
|
+
return nil unless value.is_a?(Hash)
|
|
128
|
+
return value["stringValue"] if value.key?("stringValue")
|
|
129
|
+
# OTLP JSON encodes int64 as a string to avoid JS precision loss.
|
|
130
|
+
return decode_int_string(value["intValue"]) if value.key?("intValue")
|
|
131
|
+
return value["doubleValue"] if value.key?("doubleValue")
|
|
132
|
+
return value["boolValue"] if value.key?("boolValue")
|
|
133
|
+
if value.key?("arrayValue")
|
|
134
|
+
values = value.dig("arrayValue", "values") || []
|
|
135
|
+
return values.map { |v| decode_any_value(v) }
|
|
136
|
+
end
|
|
137
|
+
if value.key?("kvlistValue")
|
|
138
|
+
kvs = value.dig("kvlistValue", "values") || []
|
|
139
|
+
return flatten_attributes(kvs)
|
|
140
|
+
end
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private_class_method :decode_any_value
|
|
145
|
+
|
|
146
|
+
def decode_int_string(value)
|
|
147
|
+
return value if value.is_a?(Integer)
|
|
148
|
+
return nil if value.nil?
|
|
149
|
+
Integer(value.to_s, 10)
|
|
150
|
+
rescue ArgumentError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private_class_method :decode_int_string
|
|
155
|
+
|
|
156
|
+
def dig_attributes(container)
|
|
157
|
+
container.is_a?(Hash) ? container["attributes"] : nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private_class_method :dig_attributes
|
|
161
|
+
|
|
162
|
+
# Pull dataPoints out of whichever metric type wrapper is present.
|
|
163
|
+
# Histograms and summaries (rare in Claude Code's exports) return
|
|
164
|
+
# their data points; we record the count value when present.
|
|
165
|
+
def collect_data_points(metric)
|
|
166
|
+
%w[sum gauge histogram exponentialHistogram summary].each do |kind|
|
|
167
|
+
wrapper = metric[kind]
|
|
168
|
+
next unless wrapper.is_a?(Hash)
|
|
169
|
+
points = wrapper["dataPoints"]
|
|
170
|
+
return Array(points) if points
|
|
171
|
+
end
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private_class_method :collect_data_points
|
|
176
|
+
|
|
177
|
+
# Returns [value_type, value_int, value_float] tuple. nil value_type
|
|
178
|
+
# means we couldn't decode anything storable.
|
|
179
|
+
def decode_metric_value(point)
|
|
180
|
+
if point.key?("asInt")
|
|
181
|
+
int_value = decode_int_string(point["asInt"])
|
|
182
|
+
return [ValueType::INT, int_value, nil] unless int_value.nil?
|
|
183
|
+
end
|
|
184
|
+
if point.key?("asDouble")
|
|
185
|
+
d = point["asDouble"]
|
|
186
|
+
return [ValueType::DOUBLE, nil, d.to_f] unless d.nil?
|
|
187
|
+
end
|
|
188
|
+
# Histogram / summary fall-back: store sum when present, count
|
|
189
|
+
# otherwise. Skip when neither is available.
|
|
190
|
+
if point.key?("sum")
|
|
191
|
+
s = point["sum"]
|
|
192
|
+
return [ValueType::DOUBLE, nil, s.to_f] unless s.nil?
|
|
193
|
+
end
|
|
194
|
+
if point.key?("count")
|
|
195
|
+
count = decode_int_string(point["count"])
|
|
196
|
+
return [ValueType::INT, count, nil] unless count.nil?
|
|
197
|
+
end
|
|
198
|
+
[nil, nil, nil]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private_class_method :decode_metric_value
|
|
202
|
+
|
|
203
|
+
def event_name_for(record, attributes)
|
|
204
|
+
# OTel logs/events: the event name lives on the attribute
|
|
205
|
+
# "event.name" by convention. Claude Code sets it as
|
|
206
|
+
# "claude_code.<name>" on its instrumentation scope, so we strip
|
|
207
|
+
# the prefix when present so panels see "user_prompt", not
|
|
208
|
+
# "claude_code.user_prompt".
|
|
209
|
+
raw = attributes["event.name"] || record["eventName"] || record.dig("body", "stringValue") || "log"
|
|
210
|
+
raw.to_s.sub(/\Aclaude_code\./, "")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private_class_method :event_name_for
|
|
214
|
+
|
|
215
|
+
def timestamp_from_point(point, clock)
|
|
216
|
+
nano = parse_unix_nano(point["timeUnixNano"]) || parse_unix_nano(point["startTimeUnixNano"])
|
|
217
|
+
timestamp_from_unix_nano(nano, clock)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private_class_method :timestamp_from_point
|
|
221
|
+
|
|
222
|
+
def timestamp_from_record(record, clock)
|
|
223
|
+
nano = parse_unix_nano(record["timeUnixNano"]) || parse_unix_nano(record["observedTimeUnixNano"])
|
|
224
|
+
timestamp_from_unix_nano(nano, clock)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private_class_method :timestamp_from_record
|
|
228
|
+
|
|
229
|
+
def timestamp_from_unix_nano(nano, clock)
|
|
230
|
+
return clock.now.utc.iso8601 if nano.nil?
|
|
231
|
+
Time.at(nano / 1_000_000_000.0).utc.iso8601
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private_class_method :timestamp_from_unix_nano
|
|
235
|
+
|
|
236
|
+
def parse_unix_nano(value)
|
|
237
|
+
return nil if value.nil?
|
|
238
|
+
return value if value.is_a?(Integer)
|
|
239
|
+
Integer(value.to_s, 10)
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private_class_method :parse_unix_nano
|
|
245
|
+
|
|
246
|
+
def duration_ms_from(start_nano, end_nano)
|
|
247
|
+
return nil if start_nano.nil? || end_nano.nil?
|
|
248
|
+
((end_nano - start_nano) / 1_000_000).to_i
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
private_class_method :duration_ms_from
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClaudeMemory
|
|
4
|
+
module OTel
|
|
5
|
+
# Back-tags activity_events with the OTel prompt.id after telemetry
|
|
6
|
+
# events arrive. Hook events (hook_ingest, hook_context, ...) fire
|
|
7
|
+
# ~immediately as the user's turn closes, but Claude Code batches OTel
|
|
8
|
+
# exports on the OTEL_METRIC_EXPORT_INTERVAL (default 60s), so by the
|
|
9
|
+
# time we see a prompt.id on the receiver, the activity_events for that
|
|
10
|
+
# turn already exist with prompt_id = NULL.
|
|
11
|
+
#
|
|
12
|
+
# Tagging strategy per (prompt_id, session_id) group:
|
|
13
|
+
# 1. session_id-match path — for activity_events carrying the same
|
|
14
|
+
# session_id, update those that fall in the prompt's time window.
|
|
15
|
+
# Hook events (hook_ingest, hook_context, hook_sweep, hook_publish,
|
|
16
|
+
# roi_nudge) reliably carry session_id from the Claude Code hook
|
|
17
|
+
# payload.
|
|
18
|
+
# 2. time-window path — for activity_events with NULL session_id
|
|
19
|
+
# (recall, store_extraction — MCP-originated; Claude Code doesn't
|
|
20
|
+
# thread session_id into plugin MCP calls per reference_mcp_session
|
|
21
|
+
# _id_gap), tag by occurred_at falling in the prompt window only.
|
|
22
|
+
#
|
|
23
|
+
# Operates on both project_store and global_store when available.
|
|
24
|
+
# Cross-project tagging (projects other than the dashboard's loaded one)
|
|
25
|
+
# is out of scope — dashboard is per-project and other project DBs
|
|
26
|
+
# aren't in the manager.
|
|
27
|
+
class PromptScope
|
|
28
|
+
# Bound the prompt window so a long-running turn doesn't sweep up
|
|
29
|
+
# later activity events that belong to the NEXT prompt. The OTel
|
|
30
|
+
# spec only emits prompt.id between user_prompt and the next
|
|
31
|
+
# user_prompt, so the natural max is implicit; we add a safety
|
|
32
|
+
# ceiling.
|
|
33
|
+
MAX_WINDOW_SECONDS = 600
|
|
34
|
+
# Buffer added after the latest OTel event because hook_ingest fires
|
|
35
|
+
# AFTER the Stop event, which can be a few seconds after the last
|
|
36
|
+
# api_request.
|
|
37
|
+
POST_WINDOW_BUFFER_SECONDS = 30
|
|
38
|
+
|
|
39
|
+
def initialize(manager)
|
|
40
|
+
@manager = manager
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param events [Array<Hash>] just-persisted OTel event rows
|
|
44
|
+
# (each carrying :prompt_id, :session_id, :occurred_at) — the
|
|
45
|
+
# same shape OtlpJsonEnvelope.parse_logs returns.
|
|
46
|
+
# @return [Hash] {tagged: count, groups: count}
|
|
47
|
+
def tag(events)
|
|
48
|
+
return {tagged: 0, groups: 0} if events.nil? || events.empty?
|
|
49
|
+
|
|
50
|
+
groups = group_by_prompt(events)
|
|
51
|
+
return {tagged: 0, groups: 0} if groups.empty?
|
|
52
|
+
|
|
53
|
+
tagged = 0
|
|
54
|
+
groups.each do |key, range|
|
|
55
|
+
prompt_id, session_id = key
|
|
56
|
+
[@manager.project_store, @manager.global_store].compact.each do |store|
|
|
57
|
+
tagged += tag_in_store(store, prompt_id, session_id, range)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
{tagged: tagged, groups: groups.size}
|
|
61
|
+
rescue Sequel::DatabaseError => e
|
|
62
|
+
ClaudeMemory.logger.debug("prompt_scope tag failed: #{e.message}")
|
|
63
|
+
{tagged: 0, groups: 0, error: e.message}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Returns {[prompt_id, session_id_or_nil] => (lo..hi)} where lo/hi
|
|
69
|
+
# are ISO 8601 strings derived from event occurred_at values.
|
|
70
|
+
def group_by_prompt(events)
|
|
71
|
+
events
|
|
72
|
+
.select { |e| e[:prompt_id] && !e[:prompt_id].to_s.empty? }
|
|
73
|
+
.group_by { |e| [e[:prompt_id], e[:session_id]] }
|
|
74
|
+
.each_with_object({}) do |(key, group), out|
|
|
75
|
+
timestamps = group.map { |e| e[:occurred_at] }.compact.sort
|
|
76
|
+
next if timestamps.empty?
|
|
77
|
+
lo = timestamps.first
|
|
78
|
+
hi = (Time.parse(timestamps.last) + POST_WINDOW_BUFFER_SECONDS).utc.iso8601
|
|
79
|
+
# Cap window length to MAX_WINDOW_SECONDS so a stale event
|
|
80
|
+
# batch can't sweep a wide range.
|
|
81
|
+
ceiling = (Time.parse(lo) + MAX_WINDOW_SECONDS).utc.iso8601
|
|
82
|
+
hi = [hi, ceiling].min
|
|
83
|
+
out[key] = (lo..hi)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tag_in_store(store, prompt_id, session_id, range)
|
|
88
|
+
return 0 unless store&.db&.table_exists?(:activity_events)
|
|
89
|
+
return 0 unless store.activity_events.columns.include?(:prompt_id)
|
|
90
|
+
|
|
91
|
+
tagged_by_session = 0
|
|
92
|
+
if session_id && !session_id.to_s.empty?
|
|
93
|
+
tagged_by_session = store.activity_events
|
|
94
|
+
.where(session_id: session_id, prompt_id: nil)
|
|
95
|
+
.where { (occurred_at >= range.first) & (occurred_at <= range.last) }
|
|
96
|
+
.update(prompt_id: prompt_id)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
tagged_by_window = store.activity_events
|
|
100
|
+
.where(session_id: nil, prompt_id: nil)
|
|
101
|
+
.where { (occurred_at >= range.first) & (occurred_at <= range.last) }
|
|
102
|
+
.update(prompt_id: prompt_id)
|
|
103
|
+
|
|
104
|
+
tagged_by_session + tagged_by_window
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|