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

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.



17
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
# File 'lib/talk_to_your_app/audit_logger.rb', line 17

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.message}")
    end
  end
end

.build_fields(plugin_name, tool_class, params, outcome, error_class, duration_ms) ⇒ Object

The structured audit payload for one tool invocation.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/talk_to_your_app/audit_logger.rb', line 62

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_loggerObject



102
103
104
105
106
107
108
109
# File 'lib/talk_to_your_app/audit_logger.rb', line 102

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



49
50
51
52
53
54
55
56
57
58
59
# File 'lib/talk_to_your_app/audit_logger.rb', line 49

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



78
79
80
# File 'lib/talk_to_your_app/audit_logger.rb', line 78

def format_line(fields)
  "talk_to_your_app " + fields.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ")
end

.format_value(value) ⇒ Object



90
91
92
93
94
95
96
# File 'lib/talk_to_your_app/audit_logger.rb', line 90

def format_value(value)
  case value
  when Hash, Array then value.to_json
  when nil then "-"
  else value.to_s
  end
end

.loggerObject



98
99
100
# File 'lib/talk_to_your_app/audit_logger.rb', line 98

def logger
  TalkToYourApp.configuration.logger || default_logger
end

.monotonicObject



111
112
113
# File 'lib/talk_to_your_app/audit_logger.rb', line 111

def monotonic
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
end

.redact(tool_class, params) ⇒ Object

Replaces any argument declared with ‘redact: true` with [REDACTED].



83
84
85
86
87
88
# File 'lib/talk_to_your_app/audit_logger.rb', line 83

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