Class: TrackRelay::Linter

Inherits:
Object
  • Object
show all
Defined in:
lib/track_relay/linter.rb

Overview

Audits the JSONL untyped-event sink written by Subscribers::Logger and produces a deduped report grouped by event name + sorted-param-name signature.

## Input contract (locked in Plan 05 / 01-CONTEXT.md)

The JSONL sink contains one event per line. Each line is JSON with the canonical shape:

{"event":"...", "params":["a","b"], "controller":"...", "action":"...", "timestamp":"..."}

‘params` carries only sorted, stringified parameter NAMES — values are never written to the sink (privacy contract from 01-CONTEXT.md). The linter reads the same shape and dedupes only on `event` + sorted `params`; `controller`, `action`, and `timestamp` are accepted but ignored for grouping (they are useful breadcrumbs for the human reading the JSONL directly, not signal for dedup).

## Output

  • #report → Array of Report structs, sorted by total occurrences descending. Each Report bundles every distinct param signature seen for that event name.

  • #print → human-readable summary written to an IO.

  • #to_json → machine-readable JSON with stable keys ‘total, signatures: [{params, count]}`. Plan 09’s CHANGELOG references this contract.

## Resilience

  • Missing files return an empty report (the JSONL may legitimately not exist yet on a fresh app).

  • Lines that are not valid JSON are skipped and counted in #malformed_lines.

  • Blank lines are silently skipped (not malformed).

Defined Under Namespace

Classes: Ga4Violation, Report, Signature

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(jsonl_path) ⇒ Linter

Returns a new instance of Linter.



64
65
66
67
# File 'lib/track_relay/linter.rb', line 64

def initialize(jsonl_path)
  @jsonl_path = jsonl_path
  @malformed_lines = 0
end

Instance Attribute Details

#malformed_linesInteger (readonly)

Returns count of lines that failed JSON parsing.

Returns:

  • (Integer)

    count of lines that failed JSON parsing



62
63
64
# File 'lib/track_relay/linter.rb', line 62

def malformed_lines
  @malformed_lines
end

Instance Method Details

#ga4_violationsArray<Ga4Violation>

Audit each unique event name in the JSONL sink against Validators::Ga4Constraints (REQ-28, Plan 02-04 / Scout §8).

Only the event-name shape is checked here:

- snake_case regex (`/\A[a-z][a-z0-9_]*\z/`)
- max 40 chars
- not in `GA4_RESERVED_NAMES`

Param-name validation is intentionally OUT OF SCOPE for this method — ‘params` in the JSONL is a sorted-NAMES-only privacy snapshot, but param-name shape is a per-line fact (each occurrence could fail differently) and the linter’s grouping model is built around event names. Use the call-time validation in Subscribers::Ga4MeasurementProtocol#deliver for per-payload checks; this method is the audit trail for “what event names did we ship that GA4 will silently drop?”.

Returns:

  • (Array<Ga4Violation>)

    sorted by ‘count` descending. Empty when every event name in the JSONL passes.



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/track_relay/linter.rb', line 158

def ga4_violations
  counts = Hash.new(0)
  read_lines do |entry|
    name = entry["event"]
    next if name.nil? || name.empty?
    counts[name] += 1
  end

  counts.map { |name, count|
    begin
      Validators::Ga4Constraints.validate_event_name!(name)
      nil
    rescue Ga4ConstraintError => e
      Ga4Violation.new(event_name: name, reason: e.message, count: count)
    end
  }.compact.sort_by { |v| -v.count }
end

This method returns an undefined value.

Write a human-readable summary to ‘io`.

Parameters:

  • io (IO) (defaults to: $stdout)

    writer; defaults to ‘$stdout`



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/track_relay/linter.rb', line 95

def print(io = $stdout)
  reports = report
  io.puts "# track_relay untyped event audit"
  io.puts "# source: #{@jsonl_path}"
  io.puts "# events: #{reports.size}; total occurrences: #{reports.sum(&:total)}"
  io.puts ""
  reports.each do |r|
    io.puts "event :#{r.event_name}  (#{r.total} total)"
    r.signatures.each do |sig|
      io.puts "  - params=[#{sig.params.join(", ")}]  count=#{sig.count}"
    end
    io.puts ""
  end
  io.puts "# #{@malformed_lines} malformed line(s) skipped" if @malformed_lines.positive?
end

Write the GA4 violation report to ‘io`.

Returns ‘true` when there were no violations (rake task should exit 0), `false` otherwise (rake task exits non-zero).

Parameters:

  • io (IO) (defaults to: $stdout)

    writer; defaults to ‘$stdout`

Returns:

  • (Boolean)

    ‘true` ⇒ clean, `false` ⇒ violations found



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/track_relay/linter.rb', line 183

def print_ga4(io = $stdout)
  violations = ga4_violations
  io.puts "# track_relay GA4 event-name audit"
  io.puts "# source: #{@jsonl_path}"
  io.puts "# violations: #{violations.size}"
  io.puts ""

  if violations.empty?
    io.puts "# clean — every event name passes GA4 constraints"
    return true
  end

  violations.each do |v|
    io.puts "event :#{v.event_name}  (#{v.count} occurrence#{"s" unless v.count == 1})"
    io.puts "  reason: #{v.reason}"
    io.puts ""
  end
  false
end

#reportArray<Report>

Build the deduped report.

Returns:

  • (Array<Report>)

    sorted by total occurrences descending



72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/track_relay/linter.rb', line 72

def report
  groups = Hash.new { |h, k| h[k] = Hash.new(0) }
  read_lines do |entry|
    event = entry["event"]
    signature = Array(entry["params"]).sort
    groups[event][signature] += 1
  end

  groups.map { |event, signatures|
    sig_list = signatures.map { |params, count| Signature.new(params: params, count: count) }
    total = sig_list.sum(&:count)
    Report.new(
      event_name: event,
      signatures: sig_list.sort_by { |s| -s.count },
      total: total
    )
  }.sort_by { |r| -r.total }
end

#to_jsonString

Emit machine-readable JSON.

Keys (‘event`, `total`, `signatures`, `params`, `count`) are stable — Plan 09’s CHANGELOG references this contract.

Returns:

  • (String)

    JSON



117
118
119
120
121
122
123
124
125
# File 'lib/track_relay/linter.rb', line 117

def to_json(*)
  JSON.generate(report.map { |r|
    {
      event: r.event_name,
      total: r.total,
      signatures: r.signatures.map { |s| {params: s.params, count: s.count} }
    }
  })
end