Module: TalkToYourApp::AuditLogger
- Defined in:
- lib/talk_to_your_app/audit_logger.rb
Overview
Emits exactly one structured log line per tool invocation. Wrapping happens at the tool dispatch boundary, where the principal, params, plugin, tool, and timing are all in scope. Defaults to the configured logger (Rails.logger) at the plugin's level (default INFO). Re-raises on failure so the SDK still surfaces a tool error to the client.
Class Method Summary collapse
-
.around(tool_class:, plugin_name:, log_level:, params:) ⇒ Object
Runs the block, times it, and logs the outcome.
-
.auth_failure(reason:, scheme: nil, ip: nil, error_class: nil) ⇒ Object
One line per rejected request, at WARN, plus a structured
talk_to_your_app.auth_failureevent. -
.build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms) ⇒ Object
The structured audit payload for one tool invocation.
- .default_logger ⇒ Object
- .emit(plugin_name:, tool_class:, params:, outcome:, error_class:, duration_ms:, log_level:) ⇒ Object
- .format_line(fields) ⇒ Object
- .format_value(value) ⇒ Object
- .logger ⇒ Object
- .monotonic ⇒ Object
-
.redact(tool_class, params) ⇒ Object
Replaces any argument declared with
redact: truewith [REDACTED].
Class Method Details
.around(tool_class:, plugin_name:, log_level:, params:) ⇒ Object
Runs the block, times it, and logs the outcome. Returns the block's value.
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 18 def around(tool_class:, plugin_name:, log_level:, params:) started = monotonic error_class = nil outcome = "success" begin result = yield outcome = "error" if result.is_a?(MCP::Tool::Response) && result.error? result rescue StandardError => e outcome = "error" error_class = e.class.name raise ensure # A logging failure must never replace the tool's result or its # exception (an exception raised in `ensure` would do exactly that), so # emit defensively and swallow any logger error to $stderr. begin emit( plugin_name: plugin_name, tool_class: tool_class, params: params, outcome: outcome, error_class: error_class, duration_ms: ((monotonic - started) * 1000).round(2), log_level: log_level, ) rescue StandardError => e warn("talk_to_your_app: audit logging failed: #{e.class}: #{e.}") end end end |
.auth_failure(reason:, scheme: nil, ip: nil, error_class: nil) ⇒ Object
One line per rejected request, at WARN, plus a structured
talk_to_your_app.auth_failure event. Without this a 401 leaves no trace
at all, so credential guessing and endpoint scanning are undetectable —
every successful call is logged but every failed one was invisible. The
level is fixed at :warn rather than following log_level: an auth failure
is not routine traffic. Carries no credential material — only a coarse
reason, the scheme, and the client IP.
57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 57 def auth_failure(reason:, scheme: nil, ip: nil, error_class: nil) fields = { ts: Time.now.utc.iso8601(3), event: "auth_failure", reason: reason, scheme: scheme, ip: ip } fields[:error_class] = error_class if error_class # Log line first: a subscriber that raises would otherwise suppress the # only record of a rejected request. logger.warn { format_line(fields) } ActiveSupport::Notifications.instrument("talk_to_your_app.auth_failure", fields) rescue StandardError => e # Same contract as the tool-call path: a logging failure must not turn a # 401 into a 500. warn("talk_to_your_app: auth failure logging failed: #{e.class}: #{e.}") end |
.build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms) ⇒ Object
The structured audit payload for one tool invocation.
84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 84 def build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms) fields = { ts: Time.now.utc.iso8601(3), principal: TalkToYourApp::Current.principal, session_id: TalkToYourApp::Current.session_id, ip: TalkToYourApp::Current.ip, plugin: plugin_name, tool: tool_class.tool_name, params: redact(tool_class, params), outcome: outcome, duration_ms: duration_ms, } fields[:error_class] = error_class if error_class fields end |
.default_logger ⇒ Object
124 125 126 127 128 129 130 131 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 124 def default_logger if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger Rails.logger else require "logger" @fallback_logger ||= Logger.new($stdout) end end |
.emit(plugin_name:, tool_class:, params:, outcome:, error_class:, duration_ms:, log_level:) ⇒ Object
71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 71 def emit(plugin_name:, tool_class:, params:, outcome:, error_class:, duration_ms:, log_level:) fields = build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms) # Structured event for anyone who wants to persist a full audit trail # (e.g. an Activity table with IP + principal). Subscribers receive the # `fields` hash as the payload. See the README "Custom audit logging". ActiveSupport::Notifications.instrument("talk_to_your_app.tool_call", fields) level = (log_level || TalkToYourApp.configuration.log_level || :info) logger.public_send(level) { format_line(fields) } end |
.format_line(fields) ⇒ Object
100 101 102 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 100 def format_line(fields) "talk_to_your_app " + fields.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ") end |
.format_value(value) ⇒ Object
112 113 114 115 116 117 118 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 112 def format_value(value) case value when Hash, Array then value.to_json when nil then "-" else value.to_s end end |
.logger ⇒ Object
120 121 122 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 120 def logger TalkToYourApp.configuration.logger || default_logger end |
.monotonic ⇒ Object
133 134 135 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 133 def monotonic Process.clock_gettime(Process::CLOCK_MONOTONIC) end |
.redact(tool_class, params) ⇒ Object
Replaces any argument declared with redact: true with [REDACTED].
105 106 107 108 109 110 |
# File 'lib/talk_to_your_app/audit_logger.rb', line 105 def redact(tool_class, params) params.each_with_object({}) do |(key, value), acc| opts = tool_class.arguments[key.to_sym] acc[key] = opts && opts[:redact] ? "[REDACTED]" : value end end |