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.
Files changed (277) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +361 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -2
  5. data/bin/insika +366 -0
  6. data/docs/AGENTS.md +618 -0
  7. data/docs/ARCHITECTURE.md +333 -0
  8. data/docs/BENCHMARK.md +114 -0
  9. data/docs/CHANNELS.md +453 -0
  10. data/docs/CONTEXT.md +117 -0
  11. data/docs/DEPLOY.md +354 -0
  12. data/docs/EMBEDDING.md +198 -0
  13. data/docs/EVALS.md +273 -0
  14. data/docs/LOADTEST.md +232 -0
  15. data/docs/OBSERVABILITY.md +374 -0
  16. data/docs/PLUGINS.md +211 -0
  17. data/docs/REFINEMENT.md +477 -0
  18. data/docs/RELEASING.md +70 -0
  19. data/docs/RUNNING-LOCAL.md +153 -0
  20. data/docs/SANDBOX.md +114 -0
  21. data/docs/SECURITY.md +375 -0
  22. data/docs/SKILLS.md +284 -0
  23. data/docs/TOOLS.md +302 -0
  24. data/docs/WHY.md +137 -0
  25. data/docs/WORKFLOWS.md +225 -0
  26. data/docs/build.md +14 -0
  27. data/docs/index.md +68 -0
  28. data/docs/onboarding/start.md +126 -0
  29. data/docs/operate.md +12 -0
  30. data/docs/ship.md +10 -0
  31. data/docs/understand.md +10 -0
  32. data/lib/insika/agent_file_store.rb +125 -0
  33. data/lib/insika/agent_profile.rb +255 -0
  34. data/lib/insika/alert_dispatcher.rb +139 -0
  35. data/lib/insika/allowlist.rb +28 -0
  36. data/lib/insika/baseline_store.rb +74 -0
  37. data/lib/insika/budget_ledger.rb +135 -0
  38. data/lib/insika/capability/resolved_tool.rb +34 -0
  39. data/lib/insika/capability_registry.rb +112 -0
  40. data/lib/insika/channel_delivery.rb +153 -0
  41. data/lib/insika/channel_registry.rb +30 -0
  42. data/lib/insika/channels/relay.rb +178 -0
  43. data/lib/insika/channels/web/widget.js +283 -0
  44. data/lib/insika/channels/web.rb +211 -0
  45. data/lib/insika/channels/webhook.rb +58 -0
  46. data/lib/insika/chat_builder.rb +303 -0
  47. data/lib/insika/checkpoint.rb +13 -0
  48. data/lib/insika/checkpoint_store.rb +153 -0
  49. data/lib/insika/circuit_state.rb +114 -0
  50. data/lib/insika/coercion.rb +58 -0
  51. data/lib/insika/command.rb +32 -0
  52. data/lib/insika/command_bus.rb +39 -0
  53. data/lib/insika/commands/agent_payload.rb +43 -0
  54. data/lib/insika/commands/approve_action.rb +46 -0
  55. data/lib/insika/commands/cancel_task.rb +33 -0
  56. data/lib/insika/commands/create_agent.rb +54 -0
  57. data/lib/insika/commands/create_session.rb +67 -0
  58. data/lib/insika/commands/delete_agent.rb +33 -0
  59. data/lib/insika/commands/delete_agent_file.rb +50 -0
  60. data/lib/insika/commands/delete_data_tool.rb +33 -0
  61. data/lib/insika/commands/delete_llm_provider.rb +36 -0
  62. data/lib/insika/commands/delete_mcp.rb +30 -0
  63. data/lib/insika/commands/delete_skill.rb +43 -0
  64. data/lib/insika/commands/delete_system_file.rb +29 -0
  65. data/lib/insika/commands/gate_refinement.rb +245 -0
  66. data/lib/insika/commands/import_mcp_tools.rb +48 -0
  67. data/lib/insika/commands/import_tools.rb +81 -0
  68. data/lib/insika/commands/issue_tenant_token.rb +41 -0
  69. data/lib/insika/commands/memory_add_note.rb +32 -0
  70. data/lib/insika/commands/memory_forget_fact.rb +32 -0
  71. data/lib/insika/commands/memory_put_fact.rb +35 -0
  72. data/lib/insika/commands/pause_task.rb +29 -0
  73. data/lib/insika/commands/resolve_refinement.rb +126 -0
  74. data/lib/insika/commands/restore_agent_file.rb +36 -0
  75. data/lib/insika/commands/restore_data_tool.rb +34 -0
  76. data/lib/insika/commands/restore_system_file.rb +31 -0
  77. data/lib/insika/commands/resume_task.rb +85 -0
  78. data/lib/insika/commands/revoke_token.rb +39 -0
  79. data/lib/insika/commands/rotate_tenant_token.rb +43 -0
  80. data/lib/insika/commands/run_refinement.rb +133 -0
  81. data/lib/insika/commands/send_message.rb +150 -0
  82. data/lib/insika/commands/set_agent_tools.rb +39 -0
  83. data/lib/insika/commands/set_skill_agents.rb +112 -0
  84. data/lib/insika/commands/trigger_workflow.rb +80 -0
  85. data/lib/insika/commands/update_agent.rb +49 -0
  86. data/lib/insika/commands/update_settings.rb +33 -0
  87. data/lib/insika/commands/upsert_llm_provider.rb +34 -0
  88. data/lib/insika/commands/upsert_mcp.rb +32 -0
  89. data/lib/insika/commands/write_agent_file.rb +57 -0
  90. data/lib/insika/commands/write_data_tool.rb +43 -0
  91. data/lib/insika/commands/write_golden.rb +58 -0
  92. data/lib/insika/commands/write_skill.rb +60 -0
  93. data/lib/insika/commands/write_system_file.rb +31 -0
  94. data/lib/insika/config_store.rb +89 -0
  95. data/lib/insika/context/builder.rb +166 -0
  96. data/lib/insika/context/catalog_provider.rb +23 -0
  97. data/lib/insika/context/fragment.rb +43 -0
  98. data/lib/insika/context/priority.rb +30 -0
  99. data/lib/insika/context/provider.rb +19 -0
  100. data/lib/insika/context/providers/memory.rb +60 -0
  101. data/lib/insika/context/providers/prompt.rb +105 -0
  102. data/lib/insika/context/providers/request.rb +32 -0
  103. data/lib/insika/context/providers/session.rb +123 -0
  104. data/lib/insika/context/providers/skill.rb +24 -0
  105. data/lib/insika/context/providers/skill_trigger.rb +128 -0
  106. data/lib/insika/context/providers/tool_search.rb +20 -0
  107. data/lib/insika/context_trace_store.rb +92 -0
  108. data/lib/insika/delegation_store.rb +153 -0
  109. data/lib/insika/doctor.rb +539 -0
  110. data/lib/insika/dsl/definition.rb +55 -0
  111. data/lib/insika/dsl/runtime.rb +382 -0
  112. data/lib/insika/dsl/server_boot.rb +98 -0
  113. data/lib/insika/dsl/system.rb +93 -0
  114. data/lib/insika/dsl/workflow_adapter.rb +59 -0
  115. data/lib/insika/dsl.rb +364 -0
  116. data/lib/insika/edge_limiter.rb +268 -0
  117. data/lib/insika/egress_guard.rb +75 -0
  118. data/lib/insika/env_schema.rb +249 -0
  119. data/lib/insika/errors.rb +201 -0
  120. data/lib/insika/evals/assertions.rb +247 -0
  121. data/lib/insika/evals/baseline.rb +69 -0
  122. data/lib/insika/evals/golden.rb +172 -0
  123. data/lib/insika/evals/judge.rb +225 -0
  124. data/lib/insika/evals/pairwise.rb +178 -0
  125. data/lib/insika/evals/report.rb +115 -0
  126. data/lib/insika/evals/runner.rb +141 -0
  127. data/lib/insika/evals/transport.rb +178 -0
  128. data/lib/insika/event.rb +18 -0
  129. data/lib/insika/event_stream.rb +132 -0
  130. data/lib/insika/executor.rb +1995 -0
  131. data/lib/insika/frontmatter.rb +42 -0
  132. data/lib/insika/golden_store.rb +145 -0
  133. data/lib/insika/hooks.rb +48 -0
  134. data/lib/insika/http_client.rb +63 -0
  135. data/lib/insika/inbound_log.rb +84 -0
  136. data/lib/insika/llm_configurator.rb +99 -0
  137. data/lib/insika/llm_provider_store.rb +83 -0
  138. data/lib/insika/loop_detector.rb +143 -0
  139. data/lib/insika/mcp_http_client.rb +67 -0
  140. data/lib/insika/mcp_store.rb +115 -0
  141. data/lib/insika/mcp_tool_ingestor.rb +143 -0
  142. data/lib/insika/memory_store.rb +93 -0
  143. data/lib/insika/message_origin.rb +76 -0
  144. data/lib/insika/middleware.rb +36 -0
  145. data/lib/insika/model_policy.rb +52 -0
  146. data/lib/insika/model_resolver.rb +176 -0
  147. data/lib/insika/model_selection.rb +115 -0
  148. data/lib/insika/onboarding.rb +208 -0
  149. data/lib/insika/outbox_store.rb +166 -0
  150. data/lib/insika/overlay_tool_registry.rb +102 -0
  151. data/lib/insika/pack.rb +102 -0
  152. data/lib/insika/pack_importer.rb +123 -0
  153. data/lib/insika/pending_action_store.rb +120 -0
  154. data/lib/insika/plugin/loader.rb +356 -0
  155. data/lib/insika/plugin.rb +35 -0
  156. data/lib/insika/policy/engine.rb +83 -0
  157. data/lib/insika/policy/policy.rb +120 -0
  158. data/lib/insika/policy_registry.rb +23 -0
  159. data/lib/insika/profile_source.rb +143 -0
  160. data/lib/insika/prompt_catalog.rb +61 -0
  161. data/lib/insika/provider_error_classifier.rb +160 -0
  162. data/lib/insika/queue_policy.rb +167 -0
  163. data/lib/insika/recovery.rb +168 -0
  164. data/lib/insika/refinement/candidate.rb +159 -0
  165. data/lib/insika/refinement/evidence_collector.rb +371 -0
  166. data/lib/insika/refinement/gate.rb +234 -0
  167. data/lib/insika/refinement/panel.rb +222 -0
  168. data/lib/insika/refinement/proposer.rb +262 -0
  169. data/lib/insika/refinement_store.rb +295 -0
  170. data/lib/insika/registry.rb +59 -0
  171. data/lib/insika/reliability.rb +185 -0
  172. data/lib/insika/safety/config.rb +109 -0
  173. data/lib/insika/safety/detectors.rb +176 -0
  174. data/lib/insika/safety/factory.rb +102 -0
  175. data/lib/insika/safety/input_guardrail.rb +102 -0
  176. data/lib/insika/safety/moderator.rb +94 -0
  177. data/lib/insika/safety/output_filter.rb +79 -0
  178. data/lib/insika/safety/output_validator.rb +101 -0
  179. data/lib/insika/safety/safe_responses.rb +47 -0
  180. data/lib/insika/sandbox/boundary.rb +93 -0
  181. data/lib/insika/sandbox/docker.rb +74 -0
  182. data/lib/insika/sandbox/local.rb +33 -0
  183. data/lib/insika/sandbox/runner.rb +80 -0
  184. data/lib/insika/sandbox.rb +85 -0
  185. data/lib/insika/schema_guard.rb +147 -0
  186. data/lib/insika/secret_masking.rb +34 -0
  187. data/lib/insika/server/a2a/agent_card.rb +27 -0
  188. data/lib/insika/server/a2a/app.rb +112 -0
  189. data/lib/insika/server/a2a/client.rb +101 -0
  190. data/lib/insika/server/a2a/errors.rb +32 -0
  191. data/lib/insika/server/a2a/http.rb +42 -0
  192. data/lib/insika/server/a2a/message.rb +27 -0
  193. data/lib/insika/server/a2a/protocol.rb +45 -0
  194. data/lib/insika/server/a2a/remotes.rb +25 -0
  195. data/lib/insika/server/a2a/task_projection.rb +40 -0
  196. data/lib/insika/server/app.rb +1022 -0
  197. data/lib/insika/server/boot.rb +119 -0
  198. data/lib/insika/server/rack_app.rb +118 -0
  199. data/lib/insika/server/responses.rb +165 -0
  200. data/lib/insika/server/sse_body.rb +96 -0
  201. data/lib/insika/server/tenant_auth.rb +61 -0
  202. data/lib/insika/session_actor.rb +162 -0
  203. data/lib/insika/session_store.rb +143 -0
  204. data/lib/insika/settings_store.rb +154 -0
  205. data/lib/insika/shutdown.rb +125 -0
  206. data/lib/insika/skill_catalog.rb +220 -0
  207. data/lib/insika/skill_store.rb +127 -0
  208. data/lib/insika/steer_injector.rb +110 -0
  209. data/lib/insika/store.rb +52 -0
  210. data/lib/insika/stores/memory.rb +123 -0
  211. data/lib/insika/stores/sqlite.rb +183 -0
  212. data/lib/insika/studio/app.rb +1693 -0
  213. data/lib/insika/studio/assets/dist/application.css +1 -0
  214. data/lib/insika/studio/assets/dist/application.js +70 -0
  215. data/lib/insika/studio/forms.rb +335 -0
  216. data/lib/insika/studio/nav_icons.rb +31 -0
  217. data/lib/insika/studio/views/_message.erb +44 -0
  218. data/lib/insika/studio/views/agent_detail.erb +285 -0
  219. data/lib/insika/studio/views/agents.erb +63 -0
  220. data/lib/insika/studio/views/approvals.erb +41 -0
  221. data/lib/insika/studio/views/chats.erb +34 -0
  222. data/lib/insika/studio/views/evals.erb +83 -0
  223. data/lib/insika/studio/views/home.erb +72 -0
  224. data/lib/insika/studio/views/layout.erb +94 -0
  225. data/lib/insika/studio/views/login.erb +17 -0
  226. data/lib/insika/studio/views/mcp.erb +91 -0
  227. data/lib/insika/studio/views/not_found.erb +5 -0
  228. data/lib/insika/studio/views/playground.erb +47 -0
  229. data/lib/insika/studio/views/refinement.erb +234 -0
  230. data/lib/insika/studio/views/session.erb +137 -0
  231. data/lib/insika/studio/views/settings.erb +168 -0
  232. data/lib/insika/studio/views/skills.erb +141 -0
  233. data/lib/insika/studio/views/system_files.erb +65 -0
  234. data/lib/insika/studio/views/task.erb +105 -0
  235. data/lib/insika/studio/views/tasks.erb +33 -0
  236. data/lib/insika/studio/views/tool_edit.erb +107 -0
  237. data/lib/insika/studio/views/tools.erb +89 -0
  238. data/lib/insika/subagent_graph.rb +96 -0
  239. data/lib/insika/system_file_store.rb +96 -0
  240. data/lib/insika/task_actor.rb +128 -0
  241. data/lib/insika/task_store.rb +250 -0
  242. data/lib/insika/telemetry/pricing.rb +104 -0
  243. data/lib/insika/telemetry/recorder.rb +228 -0
  244. data/lib/insika/telemetry.rb +127 -0
  245. data/lib/insika/testing/store_contract.rb +270 -0
  246. data/lib/insika/tick.rb +122 -0
  247. data/lib/insika/token_estimator.rb +16 -0
  248. data/lib/insika/token_store.rb +168 -0
  249. data/lib/insika/tool_assembly.rb +140 -0
  250. data/lib/insika/tool_catalog.rb +89 -0
  251. data/lib/insika/tool_definition.rb +518 -0
  252. data/lib/insika/tool_envelope.rb +140 -0
  253. data/lib/insika/tool_manifest.rb +218 -0
  254. data/lib/insika/tool_output_compressor.rb +100 -0
  255. data/lib/insika/tool_registry.rb +21 -0
  256. data/lib/insika/tool_store.rb +135 -0
  257. data/lib/insika/tool_trace_store.rb +92 -0
  258. data/lib/insika/tools/a2a_remote.rb +48 -0
  259. data/lib/insika/tools/agent_enum.rb +68 -0
  260. data/lib/insika/tools/concurrency.rb +54 -0
  261. data/lib/insika/tools/data_defined_tool.rb +219 -0
  262. data/lib/insika/tools/load_skill.rb +99 -0
  263. data/lib/insika/tools/remember.rb +53 -0
  264. data/lib/insika/tools/stuck_signal.rb +44 -0
  265. data/lib/insika/tools/subagent.rb +75 -0
  266. data/lib/insika/tools/subagents.rb +77 -0
  267. data/lib/insika/tools/tool_search.rb +94 -0
  268. data/lib/insika/turn_output.rb +139 -0
  269. data/lib/insika/turn_state.rb +162 -0
  270. data/lib/insika/turn_timing.rb +56 -0
  271. data/lib/insika/usage_ledger.rb +47 -0
  272. data/lib/insika/version.rb +3 -1
  273. data/lib/insika/wiring/graph.rb +249 -0
  274. data/lib/insika/workflow.rb +185 -0
  275. data/lib/insika/workflow_registry.rb +33 -0
  276. data/lib/insika.rb +220 -4
  277. 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