Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ group :test do
end

group :spec do
# Using feature branch until https://github.com/Shopify/liquid-spec/pull/144 is merged
gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'self-drop-env-lookup-specs'
gem 'liquid-spec', github: 'Shopify/liquid-spec'
gem 'activesupport', require: false
end
72 changes: 72 additions & 0 deletions docs/template_recorder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Recording and replaying renders

`Liquid::TemplateRecorder` captures successful template renders so they can be
replayed without the application's file system or Drop implementations.
Recording does not wrap or replace assigns, so the recorded render has the same
semantics as a normal render.

```ruby
Liquid::TemplateRecorder.record("render.json") do
template = Liquid::Template.parse(source)
template.render!(assigns)
end

replayer = Liquid::TemplateRecorder.replay_from("render.json", mode: :verify)
replayer.render # raises if the output changed
```

A recording contains the root template, every parsed partial, partial contents,
plain Hash/Array values resolved by the template, properties actually read from `Liquid::Drop` objects,
filter-call diagnostics, engine options, and the rendered output. Drop instance
variables are never inspected. An unsupported Ruby object raises
`Liquid::TemplateRecorder::SerializationError` rather than silently producing a
recording that cannot be replayed.

## Storage formats

A `.json` destination is written atomically after the recording block succeeds.
It contains a session with every render performed by the block.

A `.jsonl` destination is append-only. Each successful top-level render is one
compact, self-contained JSON line. This is the recommended format for production
sampling: a process failure can lose at most the render being written, writers
are serialized with `flock`, and a recording can be replayed by index.

A destination may instead be any writer object responding to `write(record)`. The
writer receives one self-contained recording Hash per successful render. Liquid
does not own or close injected writers, so applications can publish records to
Kafka, object storage, or another transport without coupling that transport to
the recorder.
Pass `on_error:` to keep serialization or sink failures out of the render path;
the callback receives the error and should not raise.

```ruby
Liquid::TemplateRecorder.record(kafka_writer) do
template.render!(assigns)
end
```

```ruby
Liquid::TemplateRecorder.replay_from("renders.jsonl") # last render
Liquid::TemplateRecorder.replay_from("renders.jsonl", index: 0) # first render
Liquid::TemplateRecorder.records("renders.jsonl") # inspect all
```

Compression is intentionally separate from the schema. In particular, one
long-lived compressed stream makes appending, recovery, and selecting a render
harder. Compress rotated `.jsonl` files with the storage system of your choice;
a future compressed writer can use one independent frame per record without a
schema change.

Recording sessions are thread-local. Nested sessions in the same thread are
rejected. Existing application register names and the one-argument
`FileSystem#read_template_file` API remain unchanged.

## Replay modes

* `:compute` runs filters normally. Pass application filters with
`replayer.render(filters: MyFilters)`.
* `:strict` returns each exact recorded filter result and rejects a changed
filter sequence. This can replay application-specific or nondeterministic
filters without loading their implementations.
* `:verify` computes normally and raises when the final output differs.
1 change: 1 addition & 0 deletions lib/liquid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,4 @@ module Liquid
require 'liquid/usage'
require 'liquid/registers'
require 'liquid/template_factory'
require "liquid/template_recorder"
5 changes: 5 additions & 0 deletions lib/liquid/block_body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,15 @@ def self.raise_missing_variable_terminator(token, parse_context)

# @api private
def self.render_node(context, output, node)
recorder = TemplateRecorder.current
tag_call = recorder&.begin_tag_render(node, context)
output_start = output.length
node.render_to_output_buffer(context, output)
rescue => exc
blank_tag = !node.instance_of?(Variable) && node.blank?
rescue_render_node(context, output, node.line_number, exc, blank_tag)
ensure
recorder&.finish_tag_render(tag_call, output[output_start..]) if output_start
end

# @api private
Expand Down
2 changes: 2 additions & 0 deletions lib/liquid/context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ def find_variable(key, raise_on_not_found: true)

liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=)

recorder = @registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
recorder&.emit_variable_read(key, liquid_variable)
liquid_variable
end

Expand Down
6 changes: 5 additions & 1 deletion lib/liquid/drop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ def liquid_method_missing(method)

# called by liquid to invoke a drop
def invoke_drop(method_or_key)
if self.class.invokable?(method_or_key)
result = if self.class.invokable?(method_or_key)
send(method_or_key)
else
liquid_method_missing(method_or_key)
end

recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
recorder&.emit_drop_read(self, method_or_key, result)
result
end

def key?(_name)
Expand Down
2 changes: 2 additions & 0 deletions lib/liquid/partial_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ def self.load(template_name, context:, parse_context:)

file_system = context.registers[:file_system]
source = file_system.read_template_file(template_name)
recorder = context.registers[TemplateRecorder::REGISTER_KEY] if defined?(TemplateRecorder)
recorder&.emit_file_read(template_name, source)

parse_context.partial = true

Expand Down
6 changes: 5 additions & 1 deletion lib/liquid/strainer_template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ def filter_methods
end

def invoke(method, *args)
if self.class.invokable?(method)
result = if self.class.invokable?(method)
send(method, *args)
elsif @context.strict_filters
raise Liquid::UndefinedFilter, "undefined filter #{method}"
else
args.first
end

recorder = @context&.registers&.[](TemplateRecorder::REGISTER_KEY) if defined?(TemplateRecorder)
recorder&.emit_filter_call(method, args.first, args.drop(1), result)
result
rescue ::ArgumentError => e
raise Liquid::ArgumentError, e.message, e.backtrace
end
Expand Down
28 changes: 26 additions & 2 deletions lib/liquid/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def parse(source, options = {})

tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1)
@root = Document.parse(tokenizer, parse_context)
@template_recorder_source = source.dup.freeze if defined?(TemplateRecorder) && TemplateRecorder.current
self
end

Expand Down Expand Up @@ -141,6 +142,8 @@ def errors
def render(*args)
return '' if @root.nil?

recording_session = TemplateRecorder.current if defined?(TemplateRecorder)
recording_assigns = args.first
context = case args.first
when Liquid::Context
c = args.shift
Expand Down Expand Up @@ -180,6 +183,13 @@ def render(*args)
context.add_filters(args.pop)
end

recording = recording_session&.begin_render(self, recording_assigns, context)
if recording
recorder_registers = context.registers.static
previous_recorder = recorder_registers[TemplateRecorder::REGISTER_KEY]
recorder_registers[TemplateRecorder::REGISTER_KEY] = recording
end

# Retrying a render resets resource usage
context.resource_limits.reset

Expand All @@ -192,17 +202,31 @@ def render(*args)
previous_error_mode = context.registers.static[:template_error_mode]
context.registers.static[:template_error_mode] = @error_mode

rendered_output = nil
render_succeeded = false
begin
# render the nodelist.
@root.render_to_output_buffer(context, output || +'')
rendered_output = @root.render_to_output_buffer(context, output || +'')
render_succeeded = true
rendered_output
rescue Liquid::MemoryError => e
context.handle_error(e)
rendered_output = context.handle_error(e)
render_succeeded = true
rendered_output
ensure
if previous_error_mode
context.registers.static[:template_error_mode] = previous_error_mode
else
context.registers.static.delete(:template_error_mode)
end
if recording
if previous_recorder
recorder_registers[TemplateRecorder::REGISTER_KEY] = previous_recorder
else
recorder_registers.delete(TemplateRecorder::REGISTER_KEY)
end
recording_session.finish_render(recording, rendered_output, context, success: render_succeeded)
end
@errors = context.errors
end
end
Expand Down
Loading
Loading