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.



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



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

.loggerObject



120
121
122
# File 'lib/talk_to_your_app/audit_logger.rb', line 120

def logger
  TalkToYourApp.configuration.logger || default_logger
end

.monotonicObject



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