insika 0.0.1 → 0.2.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/CHANGELOG.md +361 -0
- data/LICENSE +21 -0
- data/README.md +136 -2
- data/bin/insika +366 -0
- data/docs/AGENTS.md +618 -0
- data/docs/ARCHITECTURE.md +333 -0
- data/docs/BENCHMARK.md +114 -0
- data/docs/CHANNELS.md +453 -0
- data/docs/CONTEXT.md +117 -0
- data/docs/DEPLOY.md +354 -0
- data/docs/EMBEDDING.md +198 -0
- data/docs/EVALS.md +273 -0
- data/docs/LOADTEST.md +232 -0
- data/docs/OBSERVABILITY.md +374 -0
- data/docs/PLUGINS.md +211 -0
- data/docs/REFINEMENT.md +477 -0
- data/docs/RELEASING.md +70 -0
- data/docs/RUNNING-LOCAL.md +153 -0
- data/docs/SANDBOX.md +114 -0
- data/docs/SECURITY.md +375 -0
- data/docs/SKILLS.md +284 -0
- data/docs/TOOLS.md +302 -0
- data/docs/WHY.md +137 -0
- data/docs/WORKFLOWS.md +225 -0
- data/docs/build.md +14 -0
- data/docs/index.md +68 -0
- data/docs/onboarding/start.md +126 -0
- data/docs/operate.md +12 -0
- data/docs/ship.md +10 -0
- data/docs/understand.md +10 -0
- data/lib/insika/agent_file_store.rb +125 -0
- data/lib/insika/agent_profile.rb +255 -0
- data/lib/insika/alert_dispatcher.rb +139 -0
- data/lib/insika/allowlist.rb +28 -0
- data/lib/insika/baseline_store.rb +74 -0
- data/lib/insika/budget_ledger.rb +135 -0
- data/lib/insika/capability/resolved_tool.rb +34 -0
- data/lib/insika/capability_registry.rb +112 -0
- data/lib/insika/channel_delivery.rb +153 -0
- data/lib/insika/channel_registry.rb +30 -0
- data/lib/insika/channels/relay.rb +178 -0
- data/lib/insika/channels/web/widget.js +283 -0
- data/lib/insika/channels/web.rb +211 -0
- data/lib/insika/channels/webhook.rb +58 -0
- data/lib/insika/chat_builder.rb +303 -0
- data/lib/insika/checkpoint.rb +13 -0
- data/lib/insika/checkpoint_store.rb +153 -0
- data/lib/insika/circuit_state.rb +114 -0
- data/lib/insika/coercion.rb +58 -0
- data/lib/insika/command.rb +32 -0
- data/lib/insika/command_bus.rb +39 -0
- data/lib/insika/commands/agent_payload.rb +43 -0
- data/lib/insika/commands/approve_action.rb +46 -0
- data/lib/insika/commands/cancel_task.rb +33 -0
- data/lib/insika/commands/create_agent.rb +54 -0
- data/lib/insika/commands/create_session.rb +67 -0
- data/lib/insika/commands/delete_agent.rb +33 -0
- data/lib/insika/commands/delete_agent_file.rb +50 -0
- data/lib/insika/commands/delete_data_tool.rb +33 -0
- data/lib/insika/commands/delete_llm_provider.rb +36 -0
- data/lib/insika/commands/delete_mcp.rb +30 -0
- data/lib/insika/commands/delete_skill.rb +43 -0
- data/lib/insika/commands/delete_system_file.rb +29 -0
- data/lib/insika/commands/gate_refinement.rb +245 -0
- data/lib/insika/commands/import_mcp_tools.rb +48 -0
- data/lib/insika/commands/import_tools.rb +81 -0
- data/lib/insika/commands/issue_tenant_token.rb +41 -0
- data/lib/insika/commands/memory_add_note.rb +32 -0
- data/lib/insika/commands/memory_forget_fact.rb +32 -0
- data/lib/insika/commands/memory_put_fact.rb +35 -0
- data/lib/insika/commands/pause_task.rb +29 -0
- data/lib/insika/commands/resolve_refinement.rb +126 -0
- data/lib/insika/commands/restore_agent_file.rb +36 -0
- data/lib/insika/commands/restore_data_tool.rb +34 -0
- data/lib/insika/commands/restore_system_file.rb +31 -0
- data/lib/insika/commands/resume_task.rb +85 -0
- data/lib/insika/commands/revoke_token.rb +39 -0
- data/lib/insika/commands/rotate_tenant_token.rb +43 -0
- data/lib/insika/commands/run_refinement.rb +133 -0
- data/lib/insika/commands/send_message.rb +150 -0
- data/lib/insika/commands/set_agent_tools.rb +39 -0
- data/lib/insika/commands/set_skill_agents.rb +112 -0
- data/lib/insika/commands/trigger_workflow.rb +80 -0
- data/lib/insika/commands/update_agent.rb +49 -0
- data/lib/insika/commands/update_settings.rb +33 -0
- data/lib/insika/commands/upsert_llm_provider.rb +34 -0
- data/lib/insika/commands/upsert_mcp.rb +32 -0
- data/lib/insika/commands/write_agent_file.rb +57 -0
- data/lib/insika/commands/write_data_tool.rb +43 -0
- data/lib/insika/commands/write_golden.rb +58 -0
- data/lib/insika/commands/write_skill.rb +60 -0
- data/lib/insika/commands/write_system_file.rb +31 -0
- data/lib/insika/config_store.rb +89 -0
- data/lib/insika/context/builder.rb +166 -0
- data/lib/insika/context/catalog_provider.rb +23 -0
- data/lib/insika/context/fragment.rb +43 -0
- data/lib/insika/context/priority.rb +30 -0
- data/lib/insika/context/provider.rb +19 -0
- data/lib/insika/context/providers/memory.rb +60 -0
- data/lib/insika/context/providers/prompt.rb +105 -0
- data/lib/insika/context/providers/request.rb +32 -0
- data/lib/insika/context/providers/session.rb +123 -0
- data/lib/insika/context/providers/skill.rb +24 -0
- data/lib/insika/context/providers/skill_trigger.rb +128 -0
- data/lib/insika/context/providers/tool_search.rb +20 -0
- data/lib/insika/context_trace_store.rb +92 -0
- data/lib/insika/delegation_store.rb +153 -0
- data/lib/insika/doctor.rb +539 -0
- data/lib/insika/dsl/definition.rb +55 -0
- data/lib/insika/dsl/runtime.rb +382 -0
- data/lib/insika/dsl/server_boot.rb +98 -0
- data/lib/insika/dsl/system.rb +93 -0
- data/lib/insika/dsl/workflow_adapter.rb +59 -0
- data/lib/insika/dsl.rb +364 -0
- data/lib/insika/edge_limiter.rb +268 -0
- data/lib/insika/egress_guard.rb +75 -0
- data/lib/insika/env_schema.rb +249 -0
- data/lib/insika/errors.rb +201 -0
- data/lib/insika/evals/assertions.rb +247 -0
- data/lib/insika/evals/baseline.rb +69 -0
- data/lib/insika/evals/golden.rb +172 -0
- data/lib/insika/evals/judge.rb +225 -0
- data/lib/insika/evals/pairwise.rb +178 -0
- data/lib/insika/evals/report.rb +115 -0
- data/lib/insika/evals/runner.rb +141 -0
- data/lib/insika/evals/transport.rb +178 -0
- data/lib/insika/event.rb +18 -0
- data/lib/insika/event_stream.rb +132 -0
- data/lib/insika/executor.rb +1995 -0
- data/lib/insika/frontmatter.rb +42 -0
- data/lib/insika/golden_store.rb +145 -0
- data/lib/insika/hooks.rb +48 -0
- data/lib/insika/http_client.rb +63 -0
- data/lib/insika/inbound_log.rb +84 -0
- data/lib/insika/llm_configurator.rb +99 -0
- data/lib/insika/llm_provider_store.rb +83 -0
- data/lib/insika/loop_detector.rb +143 -0
- data/lib/insika/mcp_http_client.rb +67 -0
- data/lib/insika/mcp_store.rb +115 -0
- data/lib/insika/mcp_tool_ingestor.rb +143 -0
- data/lib/insika/memory_store.rb +93 -0
- data/lib/insika/message_origin.rb +76 -0
- data/lib/insika/middleware.rb +36 -0
- data/lib/insika/model_policy.rb +52 -0
- data/lib/insika/model_resolver.rb +176 -0
- data/lib/insika/model_selection.rb +115 -0
- data/lib/insika/onboarding.rb +208 -0
- data/lib/insika/outbox_store.rb +166 -0
- data/lib/insika/overlay_tool_registry.rb +102 -0
- data/lib/insika/pack.rb +102 -0
- data/lib/insika/pack_importer.rb +123 -0
- data/lib/insika/pending_action_store.rb +120 -0
- data/lib/insika/plugin/loader.rb +356 -0
- data/lib/insika/plugin.rb +35 -0
- data/lib/insika/policy/engine.rb +83 -0
- data/lib/insika/policy/policy.rb +120 -0
- data/lib/insika/policy_registry.rb +23 -0
- data/lib/insika/profile_source.rb +143 -0
- data/lib/insika/prompt_catalog.rb +61 -0
- data/lib/insika/provider_error_classifier.rb +160 -0
- data/lib/insika/queue_policy.rb +167 -0
- data/lib/insika/recovery.rb +168 -0
- data/lib/insika/refinement/candidate.rb +159 -0
- data/lib/insika/refinement/evidence_collector.rb +371 -0
- data/lib/insika/refinement/gate.rb +234 -0
- data/lib/insika/refinement/panel.rb +222 -0
- data/lib/insika/refinement/proposer.rb +262 -0
- data/lib/insika/refinement_store.rb +295 -0
- data/lib/insika/registry.rb +59 -0
- data/lib/insika/reliability.rb +185 -0
- data/lib/insika/safety/config.rb +109 -0
- data/lib/insika/safety/detectors.rb +176 -0
- data/lib/insika/safety/factory.rb +102 -0
- data/lib/insika/safety/input_guardrail.rb +102 -0
- data/lib/insika/safety/moderator.rb +94 -0
- data/lib/insika/safety/output_filter.rb +79 -0
- data/lib/insika/safety/output_validator.rb +101 -0
- data/lib/insika/safety/safe_responses.rb +47 -0
- data/lib/insika/sandbox/boundary.rb +93 -0
- data/lib/insika/sandbox/docker.rb +74 -0
- data/lib/insika/sandbox/local.rb +33 -0
- data/lib/insika/sandbox/runner.rb +80 -0
- data/lib/insika/sandbox.rb +85 -0
- data/lib/insika/schema_guard.rb +147 -0
- data/lib/insika/secret_masking.rb +34 -0
- data/lib/insika/server/a2a/agent_card.rb +27 -0
- data/lib/insika/server/a2a/app.rb +112 -0
- data/lib/insika/server/a2a/client.rb +101 -0
- data/lib/insika/server/a2a/errors.rb +32 -0
- data/lib/insika/server/a2a/http.rb +42 -0
- data/lib/insika/server/a2a/message.rb +27 -0
- data/lib/insika/server/a2a/protocol.rb +45 -0
- data/lib/insika/server/a2a/remotes.rb +25 -0
- data/lib/insika/server/a2a/task_projection.rb +40 -0
- data/lib/insika/server/app.rb +1022 -0
- data/lib/insika/server/boot.rb +119 -0
- data/lib/insika/server/rack_app.rb +118 -0
- data/lib/insika/server/responses.rb +165 -0
- data/lib/insika/server/sse_body.rb +96 -0
- data/lib/insika/server/tenant_auth.rb +61 -0
- data/lib/insika/session_actor.rb +162 -0
- data/lib/insika/session_store.rb +143 -0
- data/lib/insika/settings_store.rb +154 -0
- data/lib/insika/shutdown.rb +125 -0
- data/lib/insika/skill_catalog.rb +220 -0
- data/lib/insika/skill_store.rb +127 -0
- data/lib/insika/steer_injector.rb +110 -0
- data/lib/insika/store.rb +52 -0
- data/lib/insika/stores/memory.rb +123 -0
- data/lib/insika/stores/sqlite.rb +183 -0
- data/lib/insika/studio/app.rb +1693 -0
- data/lib/insika/studio/assets/dist/application.css +1 -0
- data/lib/insika/studio/assets/dist/application.js +70 -0
- data/lib/insika/studio/forms.rb +335 -0
- data/lib/insika/studio/nav_icons.rb +31 -0
- data/lib/insika/studio/views/_message.erb +44 -0
- data/lib/insika/studio/views/agent_detail.erb +285 -0
- data/lib/insika/studio/views/agents.erb +63 -0
- data/lib/insika/studio/views/approvals.erb +41 -0
- data/lib/insika/studio/views/chats.erb +34 -0
- data/lib/insika/studio/views/evals.erb +83 -0
- data/lib/insika/studio/views/home.erb +72 -0
- data/lib/insika/studio/views/layout.erb +94 -0
- data/lib/insika/studio/views/login.erb +17 -0
- data/lib/insika/studio/views/mcp.erb +91 -0
- data/lib/insika/studio/views/not_found.erb +5 -0
- data/lib/insika/studio/views/playground.erb +47 -0
- data/lib/insika/studio/views/refinement.erb +234 -0
- data/lib/insika/studio/views/session.erb +137 -0
- data/lib/insika/studio/views/settings.erb +168 -0
- data/lib/insika/studio/views/skills.erb +141 -0
- data/lib/insika/studio/views/system_files.erb +65 -0
- data/lib/insika/studio/views/task.erb +105 -0
- data/lib/insika/studio/views/tasks.erb +33 -0
- data/lib/insika/studio/views/tool_edit.erb +107 -0
- data/lib/insika/studio/views/tools.erb +89 -0
- data/lib/insika/subagent_graph.rb +96 -0
- data/lib/insika/system_file_store.rb +96 -0
- data/lib/insika/task_actor.rb +128 -0
- data/lib/insika/task_store.rb +250 -0
- data/lib/insika/telemetry/pricing.rb +104 -0
- data/lib/insika/telemetry/recorder.rb +228 -0
- data/lib/insika/telemetry.rb +127 -0
- data/lib/insika/testing/store_contract.rb +270 -0
- data/lib/insika/tick.rb +122 -0
- data/lib/insika/token_estimator.rb +16 -0
- data/lib/insika/token_store.rb +168 -0
- data/lib/insika/tool_assembly.rb +140 -0
- data/lib/insika/tool_catalog.rb +89 -0
- data/lib/insika/tool_definition.rb +518 -0
- data/lib/insika/tool_envelope.rb +140 -0
- data/lib/insika/tool_manifest.rb +218 -0
- data/lib/insika/tool_output_compressor.rb +100 -0
- data/lib/insika/tool_registry.rb +21 -0
- data/lib/insika/tool_store.rb +135 -0
- data/lib/insika/tool_trace_store.rb +92 -0
- data/lib/insika/tools/a2a_remote.rb +48 -0
- data/lib/insika/tools/agent_enum.rb +68 -0
- data/lib/insika/tools/concurrency.rb +54 -0
- data/lib/insika/tools/data_defined_tool.rb +219 -0
- data/lib/insika/tools/load_skill.rb +99 -0
- data/lib/insika/tools/remember.rb +53 -0
- data/lib/insika/tools/stuck_signal.rb +44 -0
- data/lib/insika/tools/subagent.rb +75 -0
- data/lib/insika/tools/subagents.rb +77 -0
- data/lib/insika/tools/tool_search.rb +94 -0
- data/lib/insika/turn_output.rb +139 -0
- data/lib/insika/turn_state.rb +162 -0
- data/lib/insika/turn_timing.rb +56 -0
- data/lib/insika/usage_ledger.rb +47 -0
- data/lib/insika/version.rb +3 -1
- data/lib/insika/wiring/graph.rb +249 -0
- data/lib/insika/workflow.rb +185 -0
- data/lib/insika/workflow_registry.rb +33 -0
- data/lib/insika.rb +220 -4
- metadata +412 -8
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Telemetry
|
|
7
|
+
# Estimated cost of a turn, from a rates table given as DATA.
|
|
8
|
+
#
|
|
9
|
+
# Insika never ships prices: they change weekly, differ per contract and per
|
|
10
|
+
# region, and a stale table in the engine would be worse than no number at all.
|
|
11
|
+
# The operator declares the rates, Insika only multiplies — which is why the
|
|
12
|
+
# attribute is documented as an ESTIMATE, not a bill.
|
|
13
|
+
#
|
|
14
|
+
# Rates are **USD per million tokens** (the unit every provider publishes):
|
|
15
|
+
#
|
|
16
|
+
# { "deepseek-v4-flash" => { "input" => 0.27, "output" => 1.10,
|
|
17
|
+
# "cached_input" => 0.07, "cache_write" => 0.34 } }
|
|
18
|
+
#
|
|
19
|
+
# A key matches the model id the provider reports, with or without the
|
|
20
|
+
# `provider/` prefix (`deepseek/deepseek-v4-flash` and `deepseek-v4-flash` both hit the
|
|
21
|
+
# entry above). An UNKNOWN model -> nil: a missing price is not a zero cost, so
|
|
22
|
+
# nothing is emitted and the dashboard shows a gap instead of a lie.
|
|
23
|
+
#
|
|
24
|
+
# Token accounting (matches `Executor#usage_of`, which mirrors the providers):
|
|
25
|
+
# `cached_tokens` is a SUBSET of `input_tokens`, `cache_creation_tokens` is not.
|
|
26
|
+
# - `cached_input` given -> cached tokens are billed at that rate and
|
|
27
|
+
# subtracted from the fresh input; absent -> they stay at the input rate.
|
|
28
|
+
# - `cache_write` given -> cache-creation tokens billed at that rate; absent
|
|
29
|
+
# -> at the input rate (they are input tokens the provider did write).
|
|
30
|
+
class Pricing
|
|
31
|
+
# 12 decimals: a single cheap turn can cost ~1e-6 USD, and rounding is only
|
|
32
|
+
# here to keep float noise out of the exported attribute.
|
|
33
|
+
PRECISION = 12
|
|
34
|
+
|
|
35
|
+
# rates: Hash of model id -> Hash of rate name -> USD per million tokens.
|
|
36
|
+
# Anything not shaped like that is ignored (a bad table degrades to "no cost",
|
|
37
|
+
# never to a wrong number).
|
|
38
|
+
def initialize(rates)
|
|
39
|
+
@rates = normalize(rates)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty? = @rates.empty?
|
|
43
|
+
|
|
44
|
+
# -> Float (USD) | nil when the model is absent/unpriced.
|
|
45
|
+
def cost(usage)
|
|
46
|
+
return nil if usage.nil?
|
|
47
|
+
|
|
48
|
+
rate = rate_for(usage[:model]) or return nil
|
|
49
|
+
|
|
50
|
+
input = usage[:input_tokens].to_i
|
|
51
|
+
cached = usage[:cached_tokens].to_i
|
|
52
|
+
written = usage[:cache_creation_tokens].to_i
|
|
53
|
+
output = usage[:output_tokens].to_i
|
|
54
|
+
|
|
55
|
+
cached_rate = rate["cached_input"]
|
|
56
|
+
fresh = cached_rate ? [input - cached, 0].max : input
|
|
57
|
+
|
|
58
|
+
millionths = fresh * rate["input"].to_f +
|
|
59
|
+
(cached_rate ? cached * cached_rate.to_f : 0.0) +
|
|
60
|
+
written * (rate["cache_write"] || rate["input"]).to_f +
|
|
61
|
+
output * rate["output"].to_f
|
|
62
|
+
(millionths / 1_000_000.0).round(PRECISION)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Parses the operator's table. Accepts a JSON object; anything else (blank,
|
|
66
|
+
# malformed, not an object) -> an EMPTY Pricing, never an exception: telemetry
|
|
67
|
+
# config must not be able to stop a boot.
|
|
68
|
+
def self.parse(json)
|
|
69
|
+
return new({}) if json.nil? || json.to_s.strip.empty?
|
|
70
|
+
|
|
71
|
+
parsed = JSON.parse(json.to_s)
|
|
72
|
+
new(parsed.is_a?(Hash) ? parsed : {})
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
new({})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Indexes each entry under BOTH spellings (full id and the part after the
|
|
80
|
+
# last "/") so a lookup is a single hash hit, never a scan.
|
|
81
|
+
def normalize(rates)
|
|
82
|
+
return {} unless rates.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
rates.each_with_object({}) do |(model, rate), acc|
|
|
85
|
+
next unless rate.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
entry = rate.transform_keys(&:to_s)
|
|
88
|
+
next unless entry["input"] || entry["output"]
|
|
89
|
+
|
|
90
|
+
key = model.to_s
|
|
91
|
+
acc[key] = entry
|
|
92
|
+
acc[key.split("/").last] ||= entry
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def rate_for(model)
|
|
97
|
+
key = model.to_s
|
|
98
|
+
return nil if key.empty?
|
|
99
|
+
|
|
100
|
+
@rates[key] || @rates[key.split("/").last]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Insika
|
|
6
|
+
module Telemetry
|
|
7
|
+
# Translates the insika Event Stream into OTEL SPANS and METRICS — the
|
|
8
|
+
# already-existing observability spine becomes telemetry without touching the
|
|
9
|
+
# core (Events observe; Telemetry only consumes).
|
|
10
|
+
#
|
|
11
|
+
# SPANS: one per TURN (insika.turn) with child spans per tool (insika.tool /
|
|
12
|
+
# insika.data_tool), correlated by task_id. Latency is the span duration; the
|
|
13
|
+
# agent/tenant/model/tokens/cost ride as ATTRIBUTES.
|
|
14
|
+
#
|
|
15
|
+
# METRICS: the SAME events also feed counters and histograms, so
|
|
16
|
+
# volume/latency/tokens/cost are chartable WITHOUT span aggregation (which not
|
|
17
|
+
# every backend does, and none does cheaply at retention). Metric attributes are
|
|
18
|
+
# a deliberate LOW-CARDINALITY subset of the span attributes — never task_id or
|
|
19
|
+
# session_id. `meter:` nil -> spans only (the metrics SDK is optional).
|
|
20
|
+
#
|
|
21
|
+
# `pricing:` nil -> no cost attribute/metric. Cost is an ESTIMATE from an
|
|
22
|
+
# operator-declared rates table (see Pricing) — the engine ships no prices.
|
|
23
|
+
#
|
|
24
|
+
# PURE/testable: talks to a DUCK-TYPED `tracer` (start_span/set_attribute/
|
|
25
|
+
# record_error/finish) and `meter` (create_counter/create_histogram -> add/
|
|
26
|
+
# record) — the real OTEL adapters are injected in Telemetry.setup, fakes in
|
|
27
|
+
# tests. Does NOT reference OpenTelemetry:: (loads without the gem).
|
|
28
|
+
#
|
|
29
|
+
# Robust: `record` NEVER raises (telemetry doesn't bring down a turn). Timestamps
|
|
30
|
+
# come from each event's `meta.at` (spans reconstructed with real time).
|
|
31
|
+
class Recorder
|
|
32
|
+
Turn = Struct.new(:span, :tools, :labels, :start) # tools = FIFO queue of open tools
|
|
33
|
+
OpenTool = Struct.new(:span, :name, :start)
|
|
34
|
+
|
|
35
|
+
# Ceiling of open turns: a kill -9 without a terminal event would leave the turn
|
|
36
|
+
# hanging; when exceeded, the oldest is closed (defensive, bounded memory).
|
|
37
|
+
MAX_OPEN = 1_000
|
|
38
|
+
|
|
39
|
+
# The metric instruments of the convention, created once per Recorder. Names
|
|
40
|
+
# and units are part of the documented contract (docs/OBSERVABILITY.md) —
|
|
41
|
+
# renaming one breaks every dashboard built on it.
|
|
42
|
+
class Instruments
|
|
43
|
+
attr_reader :turns, :turn_duration, :tokens, :cost, :tool_calls, :tool_duration
|
|
44
|
+
|
|
45
|
+
def initialize(meter)
|
|
46
|
+
@turns = meter.create_counter("insika.turns", unit: "{turn}",
|
|
47
|
+
description: "Turns completed, by outcome")
|
|
48
|
+
@turn_duration = meter.create_histogram("insika.turn.duration", unit: "s",
|
|
49
|
+
description: "Wall time of a turn")
|
|
50
|
+
@tokens = meter.create_counter("insika.tokens", unit: "{token}",
|
|
51
|
+
description: "Tokens consumed, by type")
|
|
52
|
+
@cost = meter.create_counter("insika.cost", unit: "{USD}",
|
|
53
|
+
description: "Estimated turn cost in USD")
|
|
54
|
+
@tool_calls = meter.create_counter("insika.tool.calls", unit: "{call}",
|
|
55
|
+
description: "Tool invocations")
|
|
56
|
+
@tool_duration = meter.create_histogram("insika.tool.duration", unit: "s",
|
|
57
|
+
description: "Wall time of a tool call")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(tracer:, meter: nil, pricing: nil)
|
|
62
|
+
@tracer = tracer
|
|
63
|
+
@instruments = meter && Instruments.new(meter)
|
|
64
|
+
@pricing = pricing
|
|
65
|
+
@turns = {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record(event)
|
|
69
|
+
meta = event.meta || {}
|
|
70
|
+
data = event.data || {}
|
|
71
|
+
case event.type
|
|
72
|
+
when :task_started then start_turn(meta, data)
|
|
73
|
+
when :tool_call then start_tool(meta, data)
|
|
74
|
+
when :tool_result then finish_tool(meta)
|
|
75
|
+
when :data_tool_call then point_tool(meta, data)
|
|
76
|
+
when :task_completed then finish_turn(meta, data, :ok)
|
|
77
|
+
when :task_failed then finish_turn(meta, data, :error)
|
|
78
|
+
when :task_cancelled then finish_turn(meta, data, :cancelled)
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
rescue StandardError
|
|
82
|
+
nil # telemetry NEVER brings down the consumer/turn
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def start_turn(meta, data)
|
|
88
|
+
id = meta[:task_id] or return
|
|
89
|
+
evict_oldest if @turns.size >= MAX_OPEN
|
|
90
|
+
at = ts(meta[:at])
|
|
91
|
+
# The metric label base: agent/tenant/command only — the low-cardinality
|
|
92
|
+
# dimensions a dashboard groups by. task_id/session_id stay on the span.
|
|
93
|
+
labels = attrs("insika.agent" => data[:agent], "insika.tenant" => data[:tenant],
|
|
94
|
+
"insika.command" => data[:command]&.to_s)
|
|
95
|
+
span = @tracer.start_span(
|
|
96
|
+
"insika.turn", parent: nil, start_time: at,
|
|
97
|
+
attributes: labels.merge(attrs("insika.task_id" => id, "insika.session_id" => meta[:session_id]))
|
|
98
|
+
)
|
|
99
|
+
@turns[id] = Turn.new(span, [], labels, at)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def start_tool(meta, data)
|
|
103
|
+
turn = @turns[meta[:task_id]] or return
|
|
104
|
+
at = ts(meta[:at])
|
|
105
|
+
name = data[:name]&.to_s
|
|
106
|
+
span = @tracer.start_span("insika.tool", parent: turn.span, start_time: at,
|
|
107
|
+
attributes: attrs("insika.tool" => name))
|
|
108
|
+
turn.tools << OpenTool.new(span, name, at)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# FIFO: the model calls a tool and receives the result before the next one, so
|
|
112
|
+
# the result matches the first open tool span of the turn.
|
|
113
|
+
def finish_tool(meta)
|
|
114
|
+
turn = @turns[meta[:task_id]] or return
|
|
115
|
+
tool = turn.tools.shift or return
|
|
116
|
+
at = ts(meta[:at])
|
|
117
|
+
tool.span.finish(end_time: at)
|
|
118
|
+
count_tool(turn, tool.name, "tool", elapsed(tool.start, at))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# data-tool emits a single event (name + HTTP status) -> point span.
|
|
122
|
+
def point_tool(meta, data)
|
|
123
|
+
turn = @turns[meta[:task_id]] or return
|
|
124
|
+
at = ts(meta[:at])
|
|
125
|
+
name = data[:tool]&.to_s
|
|
126
|
+
span = @tracer.start_span("insika.data_tool", parent: turn.span, start_time: at,
|
|
127
|
+
attributes: attrs("insika.tool" => name,
|
|
128
|
+
"insika.http.status" => data[:status]))
|
|
129
|
+
span.finish(end_time: at)
|
|
130
|
+
count_tool(turn, name, "data_tool", nil, "insika.http.status" => data[:status])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def finish_turn(meta, data, status)
|
|
134
|
+
turn = @turns.delete(meta[:task_id]) or return
|
|
135
|
+
at = ts(meta[:at])
|
|
136
|
+
usage = data[:usage]
|
|
137
|
+
set_usage(turn.span, usage)
|
|
138
|
+
turn.span.set_attribute("insika.status", status.to_s)
|
|
139
|
+
turn.span.record_error(data[:message].to_s) if status == :error
|
|
140
|
+
turn.tools.each { |t| t.span.finish(end_time: at) } # orphans (failure mid-way)
|
|
141
|
+
turn.span.finish(end_time: at)
|
|
142
|
+
count_turn(turn, usage, status.to_s, elapsed(turn.start, at))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set_usage(span, usage)
|
|
146
|
+
return unless usage
|
|
147
|
+
|
|
148
|
+
span.set_attribute("insika.tokens.input", usage[:input_tokens]) if usage[:input_tokens]
|
|
149
|
+
span.set_attribute("insika.tokens.output", usage[:output_tokens]) if usage[:output_tokens]
|
|
150
|
+
span.set_attribute("insika.tokens.total", usage[:total_tokens]) if usage[:total_tokens]
|
|
151
|
+
span.set_attribute("insika.tokens.cached", usage[:cached_tokens]) if usage[:cached_tokens]
|
|
152
|
+
span.set_attribute("insika.tokens.cache_creation", usage[:cache_creation_tokens]) if usage[:cache_creation_tokens]
|
|
153
|
+
span.set_attribute("insika.model", usage[:model].to_s) if usage[:model]
|
|
154
|
+
span.set_attribute("insika.model_source", usage[:model_source].to_s) if usage[:model_source]
|
|
155
|
+
cost = estimated_cost(usage)
|
|
156
|
+
span.set_attribute("insika.cost.usd", cost) if cost
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- metrics (no-op when no meter was injected) ------------------------
|
|
160
|
+
|
|
161
|
+
def count_turn(turn, usage, status, seconds)
|
|
162
|
+
return unless @instruments
|
|
163
|
+
|
|
164
|
+
model = usage && usage[:model]
|
|
165
|
+
labels = turn.labels.merge(attrs("insika.status" => status, "insika.model" => model&.to_s))
|
|
166
|
+
@instruments.turns.add(1, attributes: labels)
|
|
167
|
+
@instruments.turn_duration.record(seconds, attributes: labels) if seconds
|
|
168
|
+
count_usage(turn, usage)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Tokens ride ONE counter split by `insika.token.type` (instead of four
|
|
172
|
+
# instruments) so a dashboard sums or splits them with the same query.
|
|
173
|
+
def count_usage(turn, usage)
|
|
174
|
+
return unless usage
|
|
175
|
+
|
|
176
|
+
base = turn.labels.merge(attrs("insika.model" => usage[:model]&.to_s))
|
|
177
|
+
{ "input" => usage[:input_tokens], "output" => usage[:output_tokens],
|
|
178
|
+
"cached" => usage[:cached_tokens], "cache_creation" => usage[:cache_creation_tokens] }.each do |type, n|
|
|
179
|
+
@instruments.tokens.add(n.to_i, attributes: base.merge("insika.token.type" => type)) if n
|
|
180
|
+
end
|
|
181
|
+
cost = estimated_cost(usage)
|
|
182
|
+
@instruments.cost.add(cost, attributes: base) if cost
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def count_tool(turn, name, kind, seconds, extra = {})
|
|
186
|
+
return unless @instruments
|
|
187
|
+
|
|
188
|
+
labels = turn.labels.merge(attrs({ "insika.tool" => name, "insika.tool.kind" => kind }.merge(extra)))
|
|
189
|
+
@instruments.tool_calls.add(1, attributes: labels)
|
|
190
|
+
@instruments.tool_duration.record(seconds, attributes: labels) if seconds
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def estimated_cost(usage) = @pricing&.cost(usage)
|
|
194
|
+
|
|
195
|
+
# -----------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def evict_oldest
|
|
198
|
+
_id, turn = @turns.shift
|
|
199
|
+
return unless turn
|
|
200
|
+
|
|
201
|
+
turn.tools.each { |t| t.span.finish(end_time: nil) }
|
|
202
|
+
turn.span.set_attribute("insika.status", "abandoned")
|
|
203
|
+
turn.span.finish(end_time: nil)
|
|
204
|
+
count_turn(turn, nil, "abandoned", nil)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# OTEL doesn't accept an attribute with a nil value — drops the absent keys.
|
|
208
|
+
def attrs(hash) = hash.reject { |_, v| v.nil? }
|
|
209
|
+
|
|
210
|
+
# Seconds between two reconstructed timestamps; nil when either is unknown
|
|
211
|
+
# (a histogram must not record a made-up duration).
|
|
212
|
+
def elapsed(from, to)
|
|
213
|
+
return nil if from.nil? || to.nil?
|
|
214
|
+
|
|
215
|
+
[to - from, 0.0].max.to_f
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# ISO8601 (meta.at) -> Time; nil-safe (the span uses "now" when nil).
|
|
219
|
+
def ts(at)
|
|
220
|
+
return nil if at.nil? || at.to_s.empty?
|
|
221
|
+
|
|
222
|
+
Time.parse(at.to_s)
|
|
223
|
+
rescue ArgumentError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require_relative "telemetry/pricing"
|
|
5
|
+
require_relative "telemetry/recorder"
|
|
6
|
+
|
|
7
|
+
module Insika
|
|
8
|
+
# OPT-IN observability: OTEL mounted at the edge, core untouched. Rides
|
|
9
|
+
# the Event Stream — the Recorder consumes the events and emits spans and metrics.
|
|
10
|
+
# Off (the default) -> `setup` returns nil and nothing is loaded or instrumented
|
|
11
|
+
# (parity, zero overhead). The OTEL gems are only REQUIRED lazily in `setup`
|
|
12
|
+
# (enabled), never at core load — like ruby_llm in the Executor.
|
|
13
|
+
#
|
|
14
|
+
# Turn on: `INSIKA_OTEL=1` OR the standard OTEL envs
|
|
15
|
+
# (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`). The destination/protocol
|
|
16
|
+
# follows the OTEL SDK's default config (env) — SigNoz/Tempo/Jaeger/Collector. The
|
|
17
|
+
# OTEL_* keys are the OpenTelemetry SDK's own env and stay verbatim; only our opt-in
|
|
18
|
+
# toggle is renamed (INSIKA_OTEL, with the HARNESS_OTEL alias still read).
|
|
19
|
+
#
|
|
20
|
+
# Metrics ride the same switch and the SAME standard env: the SDK
|
|
21
|
+
# registers a periodic metric reader when the OTLP metrics exporter is loadable,
|
|
22
|
+
# and `OTEL_METRICS_EXPORTER=none` turns metrics off while traces stay on. No
|
|
23
|
+
# Insika-specific toggle is invented for it.
|
|
24
|
+
module Telemetry
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def enabled?(env = ENV)
|
|
28
|
+
truthy(Insika::EnvSchema.read("INSIKA_OTEL", env)) ||
|
|
29
|
+
present?(env["OTEL_EXPORTER_OTLP_ENDPOINT"]) ||
|
|
30
|
+
present?(env["OTEL_TRACES_EXPORTER"])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# -> Recorder wired to the real OTEL | nil (disabled). Idempotent per process
|
|
34
|
+
# (configures the SDK once). It's the gem BOUNDARY: not covered by unit tests (like
|
|
35
|
+
# the Executor's create_chat); the Recorder's logic is tested with a fake tracer.
|
|
36
|
+
def setup(service_name: "insika", env: ENV)
|
|
37
|
+
return nil unless enabled?(env)
|
|
38
|
+
|
|
39
|
+
require "opentelemetry/sdk"
|
|
40
|
+
require "opentelemetry/exporter/otlp"
|
|
41
|
+
load_metrics_sdk
|
|
42
|
+
unless @configured
|
|
43
|
+
OpenTelemetry::SDK.configure { |c| c.service_name = service_name }
|
|
44
|
+
@configured = true
|
|
45
|
+
end
|
|
46
|
+
meter = otel_meter
|
|
47
|
+
@metrics = !meter.nil?
|
|
48
|
+
Recorder.new(tracer: OTelTracer.new(OpenTelemetry.tracer_provider.tracer("insika")),
|
|
49
|
+
meter: meter, pricing: pricing(env))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Did `setup` wire the metric instruments too (SDK present, at least one reader)?
|
|
53
|
+
# Only meaningful after `setup` — it exists for the boot banners.
|
|
54
|
+
def metrics? = @metrics == true
|
|
55
|
+
|
|
56
|
+
# Wires the Recorder to the Event Stream: subscribes to ALL events and feeds the
|
|
57
|
+
# recorder in a long-lived fiber (sibling of serving). Call INSIDE the reactor
|
|
58
|
+
# (serving arm). No-op if recorder is nil. -> the Subscription (or nil).
|
|
59
|
+
def attach(event_stream:, recorder:, parent: nil)
|
|
60
|
+
return nil if recorder.nil? # nil BEFORE touching the reactor (disabled path)
|
|
61
|
+
|
|
62
|
+
parent ||= Async::Task.current
|
|
63
|
+
sub = event_stream.subscribe
|
|
64
|
+
parent.async { sub.each { |e| recorder.record(e) } }
|
|
65
|
+
sub
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Operator-declared rates (USD per million tokens) as JSON in
|
|
69
|
+
# INSIKA_MODEL_PRICING. Unset/malformed -> an empty table -> no cost is reported.
|
|
70
|
+
def pricing(env = ENV)
|
|
71
|
+
table = Pricing.parse(Insika::EnvSchema.read("INSIKA_MODEL_PRICING", env))
|
|
72
|
+
table.empty? ? nil : table
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# One home for "is this flag on?" — EnvSchema, which is also what validates the
|
|
76
|
+
# :boolean keys in `insika env`/doctor. A local copy is how the deployment's egress
|
|
77
|
+
# flags drifted into accepting only "1".
|
|
78
|
+
def truthy(value) = Insika::EnvSchema.truthy?(value)
|
|
79
|
+
def present?(value) = Insika::Coercion.present?(value)
|
|
80
|
+
|
|
81
|
+
# The metrics SDK is OPTIONAL: absent from the bundle -> traces only, never a
|
|
82
|
+
# boot failure. Present -> `SDK.configure` picks it up and registers the
|
|
83
|
+
# periodic reader from the standard OTEL_METRICS_* env.
|
|
84
|
+
def load_metrics_sdk
|
|
85
|
+
require "opentelemetry-metrics-sdk"
|
|
86
|
+
require "opentelemetry-exporter-otlp-metrics"
|
|
87
|
+
@metrics_sdk = true
|
|
88
|
+
rescue LoadError
|
|
89
|
+
@metrics_sdk = false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# -> the OTEL meter | nil. nil whenever nothing would drain the instruments —
|
|
93
|
+
# SDK absent, or every metric reader disabled (`OTEL_METRICS_EXPORTER=none`).
|
|
94
|
+
# Recording into a provider with no reader would accumulate a point per
|
|
95
|
+
# attribute set forever, so "no reader" MUST mean "no meter".
|
|
96
|
+
def otel_meter
|
|
97
|
+
return nil unless @metrics_sdk
|
|
98
|
+
|
|
99
|
+
provider = OpenTelemetry.meter_provider
|
|
100
|
+
return nil unless provider.respond_to?(:metric_readers) && !provider.metric_readers.empty?
|
|
101
|
+
|
|
102
|
+
provider.meter("insika")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- OTEL adapters (gem boundary; only instantiated after setup's require).
|
|
106
|
+
# They hide OpenTelemetry:: from the Recorder — which stays testable without the gem.
|
|
107
|
+
|
|
108
|
+
# Translates the Recorder's duck-typed contract to the OTEL tracer.
|
|
109
|
+
class OTelTracer
|
|
110
|
+
def initialize(otel) = (@otel = otel)
|
|
111
|
+
|
|
112
|
+
def start_span(name, parent:, attributes:, start_time:)
|
|
113
|
+
ctx = parent ? OpenTelemetry::Trace.context_with_span(parent.raw) : OpenTelemetry::Context.current
|
|
114
|
+
OTelSpan.new(@otel.start_span(name, with_parent: ctx, attributes: attributes, start_timestamp: start_time))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class OTelSpan
|
|
119
|
+
attr_reader :raw
|
|
120
|
+
|
|
121
|
+
def initialize(raw) = (@raw = raw)
|
|
122
|
+
def set_attribute(key, value) = @raw.set_attribute(key, value)
|
|
123
|
+
def record_error(message) = (@raw.status = OpenTelemetry::Trace::Status.error(message))
|
|
124
|
+
def finish(end_time:) = @raw.finish(end_timestamp: end_time)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|