Skip to content

Commit f499ea5

Browse files
authored
Merge pull request #5525 from rmosolgo/detailed-trace-active-record-backend
ActiveRecord support for DetailedTrace
2 parents 3373a99 + 7743b23 commit f499ea5

8 files changed

Lines changed: 288 additions & 9 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
require 'rails/generators/active_record'
3+
4+
module Graphql
5+
module Generators
6+
class DetailedTraceGenerator < ::Rails::Generators::Base
7+
include ::Rails::Generators::Migration
8+
desc "Install GraphQL::Tracing::DetailedTrace for your schema"
9+
source_root File.expand_path('../templates', __FILE__)
10+
11+
class_option :redis,
12+
type: :boolean,
13+
default: false,
14+
desc: "Use Redis for persistence instead of ActiveRecord"
15+
16+
def self.next_migration_number(dirname)
17+
::ActiveRecord::Generators::Base.next_migration_number(dirname)
18+
end
19+
20+
def install_detailed_traces
21+
22+
schema_glob = File.expand_path("app/graphql/*_schema.rb", destination_root)
23+
schema_file = Dir.glob(schema_glob).first
24+
if !schema_file
25+
raise ArgumentError, "Failed to find schema definition file (checked: #{schema_glob.inspect})"
26+
end
27+
schema_file_match = /( *)class ([A-Za-z:]+) < GraphQL::Schema/.match(File.read(schema_file))
28+
schema_name = schema_file_match[2]
29+
indent = schema_file_match[1] + " "
30+
31+
if !options.redis?
32+
migration_template 'create_graphql_detailed_traces.erb', 'db/migrate/create_graphql_detailed_traces.rb'
33+
end
34+
35+
log :add_detailed_traces_plugin
36+
sentinel = /< GraphQL::Schema\s*\n/m
37+
code = <<-RUBY
38+
#{indent}use GraphQL::Tracing::DetailedTrace#{options.redis? ? ", redis: raise(\"TODO: pass a connection to a persistent redis database\")" : ""}, limit: 50
39+
40+
#{indent}# When this returns true, DetailedTrace will trace the query
41+
#{indent}# Could use `query.context`, `query.selected_operation_name`, `query.query_string` here
42+
#{indent}# Could call out to Flipper, etc
43+
#{indent}def self.detailed_trace?(query)
44+
#{indent} rand <= 0.000_1 # one in ten thousand
45+
#{indent}end
46+
47+
RUBY
48+
49+
in_root do
50+
inject_into_file schema_file, code, after: sentinel, force: false
51+
end
52+
53+
routes_source = File.read(File.expand_path("config/routes.rb", destination_root))
54+
already_has_dashboard = routes_source.include?("GraphQL::Dashboard") ||
55+
routes_source.include?("Schema.dashboard") ||
56+
routes_source.include?("GraphQL::Pro::Routes::Lazy")
57+
58+
if (!already_has_dashboard || behavior == :revoke)
59+
log :route, "GraphQL::Dashboard"
60+
shell.mute do
61+
route <<~RUBY
62+
# TODO: add authorization to this route and expose it in production
63+
# See https://graphql-ruby.org/pro/dashboard.html#authorizing-the-dashboard
64+
if Rails.env.development?
65+
mount GraphQL::Dashboard, at: "/graphql/dashboard", schema: #{schema_name.inspect}
66+
end
67+
68+
RUBY
69+
end
70+
end
71+
end
72+
end
73+
end
74+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class CreateGraphqlDetailedTraces < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2+
def change
3+
create_table :graphql_detailed_traces, force: true do |t|
4+
t.integer :begin_ms, null: false
5+
t.float :duration_ms, null: false
6+
t.text :trace_data, null: false
7+
t.string :operation_name, null: false
8+
end
9+
end
10+
end

lib/graphql/tracing/detailed_trace.rb

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# frozen_string_literal: true
2+
if defined?(ActiveRecord)
3+
require "graphql/tracing/detailed_trace/active_record_backend"
4+
end
25
require "graphql/tracing/detailed_trace/memory_backend"
36
require "graphql/tracing/detailed_trace/redis_backend"
47

58
module GraphQL
69
module Tracing
7-
# `DetailedTrace` can make detailed profiles for a subset of production traffic.
10+
# `DetailedTrace` can make detailed profiles for a subset of production traffic. Install it in Rails with `rails generate graphql:detailed_trace`.
811
#
912
# When `MySchema.detailed_trace?(query)` returns `true`, a profiler-specific `trace_mode: ...` will be used for the query,
1013
# overriding the one in `context[:trace_mode]`.
@@ -13,10 +16,19 @@ module Tracing
1316
# this behavior by extending {DetailedTrace} and overriding {#inspect_object}. You can opt out of debug annotations
1417
# entirely with `use ..., debug: false` or for a single query with `context: { detailed_trace_debug: false }`.
1518
#
16-
# __Redis__: The sampler stores its results in a provided Redis database. Depending on your needs,
17-
# You can configure this database to retain all data (persistent) or to expire data according to your rules.
19+
# You can store saved traces in two ways:
20+
#
21+
# - __ActiveRecord__: With `rails generate graphql:detailed_trace`, a new migration will be added to your app.
22+
# That table will be used to store trace data.
23+
#
24+
# - __Redis__: Pass `redis: ...` to save trace data to a Redis database. Depending on your needs,
25+
# you can configure this database to retain all data (persistent) or to expire data according to your rules.
26+
#
1827
# If you need to save traces indefinitely, you can download them from Perfetto after opening them there.
1928
#
29+
# @example Installing with Rails
30+
# rails generate graphql:detailed_trace # optional: --redis
31+
#
2032
# @example Adding the sampler to your schema
2133
# class MySchema < GraphQL::Schema
2234
# # Add the sampler:
@@ -53,13 +65,16 @@ class DetailedTrace
5365
# @param redis [Redis] If provided, profiles will be stored in Redis for later review
5466
# @param limit [Integer] A maximum number of profiles to store
5567
# @param debug [Boolean] if `false`, it won't create `debug` annotations in Perfetto traces (reduces overhead)
56-
def self.use(schema, trace_mode: :profile_sample, memory: false, debug: debug?, redis: nil, limit: nil)
68+
# @param model_class [Class<ActiveRecord::Base>] Overrides {ActiveRecordBackend::GraphqlDetailedTrace} if present
69+
def self.use(schema, trace_mode: :profile_sample, memory: false, debug: debug?, redis: nil, limit: nil, model_class: nil)
5770
storage = if redis
5871
RedisBackend.new(redis: redis, limit: limit)
5972
elsif memory
6073
MemoryBackend.new(limit: limit)
74+
elsif defined?(ActiveRecord)
75+
ActiveRecordBackend.new(limit: limit, model_class: model_class)
6176
else
62-
raise ArgumentError, "Pass `redis: ...` to store traces in Redis for later review"
77+
raise ArgumentError, "To store traces, install ActiveRecord or provide `redis: ...`"
6378
end
6479
detailed_trace = self.new(storage: storage, trace_mode: trace_mode, debug: debug)
6580
schema.detailed_trace = detailed_trace
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
module GraphQL
4+
module Tracing
5+
class DetailedTrace
6+
class ActiveRecordBackend
7+
class GraphqlDetailedTrace < ActiveRecord::Base
8+
end
9+
10+
def initialize(limit: nil, model_class: nil)
11+
@limit = limit
12+
@model_class = model_class || GraphqlDetailedTrace
13+
end
14+
15+
def traces(last:, before:)
16+
gdts = @model_class.all.order("begin_ms DESC")
17+
if before
18+
gdts = gdts.where("begin_ms < ?", before)
19+
end
20+
if last
21+
gdts = gdts.limit(last)
22+
end
23+
gdts.map { |gdt| record_to_stored_trace(gdt) }
24+
end
25+
26+
def delete_trace(id)
27+
@model_class.where(id: id).destroy_all
28+
nil
29+
end
30+
31+
def delete_all_traces
32+
@model_class.all.destroy_all
33+
end
34+
35+
def find_trace(id)
36+
gdt = @model_class.find_by(id: id)
37+
if gdt
38+
record_to_stored_trace(gdt)
39+
else
40+
nil
41+
end
42+
end
43+
44+
def save_trace(operation_name, duration_ms, begin_ms, trace_data)
45+
gdt = @model_class.create!(
46+
begin_ms: begin_ms,
47+
operation_name: operation_name,
48+
duration_ms: duration_ms,
49+
trace_data: Base64.encode64(trace_data),
50+
)
51+
if @limit
52+
@model_class
53+
.where("id NOT IN(SELECT id FROM graphql_detailed_traces ORDER BY begin_ms DESC LIMIT ?)", @limit)
54+
.delete_all
55+
end
56+
gdt.id
57+
end
58+
59+
private
60+
61+
def record_to_stored_trace(gdt)
62+
StoredTrace.new(
63+
id: gdt.id,
64+
begin_ms: gdt.begin_ms,
65+
operation_name: gdt.operation_name,
66+
duration_ms: gdt.duration_ms,
67+
trace_data: Base64.decode64(gdt.trace_data)
68+
)
69+
70+
end
71+
end
72+
end
73+
end
74+
end
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
require_relative "./backend_assertions"
4+
5+
if testing_rails?
6+
describe GraphQL::Tracing::DetailedTrace::ActiveRecordBackend do
7+
include GraphQLTracingDetailedTraceBackendAssertions
8+
def new_backend(**kwargs)
9+
GraphQL::Tracing::DetailedTrace::ActiveRecordBackend.new(**kwargs)
10+
end
11+
12+
class DummyModel
13+
def self.find_by(id:)
14+
OpenStruct.new(
15+
trace_data: Base64.encode64("DummyModel##{id}")
16+
)
17+
end
18+
end
19+
20+
it "can use a custom model class" do
21+
schema = Class.new(GraphQL::Schema) do
22+
use GraphQL::Tracing::DetailedTrace, model_class: DummyModel
23+
end
24+
25+
assert_equal "DummyModel#1234", schema.detailed_trace.find_trace(1234).trace_data
26+
end
27+
end
28+
end

spec/graphql/tracing/detailed_trace_spec.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,23 @@ def self.detailed_trace?(query)
4444
SamplerSchema.detailed_trace.delete_all_traces
4545
end
4646

47-
it "raises when no storage is configured" do
48-
err = assert_raises ArgumentError do
49-
Class.new(GraphQL::Schema) do
47+
if testing_rails?
48+
it "defaults to ActiveRecord" do
49+
schema = Class.new(GraphQL::Schema) do
5050
use GraphQL::Tracing::DetailedTrace
5151
end
52+
53+
assert_instance_of GraphQL::Tracing::DetailedTrace::ActiveRecordBackend, schema.detailed_trace.instance_variable_get(:@storage)
54+
end
55+
else
56+
it "raises when no storage is configured" do
57+
err = assert_raises ArgumentError do
58+
Class.new(GraphQL::Schema) do
59+
use GraphQL::Tracing::DetailedTrace
60+
end
61+
end
62+
assert_equal "To store traces, install ActiveRecord or provide `redis: ...`", err.message
5263
end
53-
assert_equal "Pass `redis: ...` to store traces in Redis for later review", err.message
5464
end
5565

5666
it "calls detailed_profile? on a Multiplex" do
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
require "spec_helper"
3+
require "generators/graphql/install_generator"
4+
require "generators/graphql/detailed_trace_generator"
5+
6+
class GraphQLGeneratorsDetailedTraceGeneratorTest < Rails::Generators::TestCase
7+
tests Graphql::Generators::DetailedTraceGenerator
8+
destination File.expand_path("../../../tmp/dummy", File.dirname(__FILE__))
9+
10+
setup do
11+
prepare_destination
12+
FileUtils.cd(File.join(destination_root, '..')) do
13+
`rails new dummy --skip-active-record --skip-test-unit --skip-spring --skip-bundle --skip-webpack-install`
14+
Graphql::Generators::InstallGenerator.start(["--skip-graphiql"], { destination_root: destination_root })
15+
end
16+
end
17+
18+
test "it creates a migration, installs a route, and adds schema configuration" do
19+
run_generator
20+
assert_migration "db/migrate/create_graphql_detailed_traces"
21+
assert_file "app/graphql/dummy_schema.rb" do |content|
22+
assert_includes content, "
23+
use GraphQL::Tracing::DetailedTrace, limit: 50
24+
25+
# When this returns true, DetailedTrace will trace the query
26+
# Could use `query.context`, `query.selected_operation_name`, `query.query_string` here
27+
# Could call out to Flipper, etc
28+
def self.detailed_trace?(query)
29+
rand <= 0.000_1 # one in ten thousand
30+
end
31+
"
32+
end
33+
34+
assert_file "config/routes.rb" do |content|
35+
assert_includes content, "mount GraphQL::Dashboard, at: \"/graphql/dashboard\", schema: \"DummySchema\""
36+
end
37+
end
38+
39+
test "it doesn't duplicate dashboard setup" do
40+
routes_path = File.expand_path("config/routes.rb", destination_root)
41+
existing_routes = File.read(routes_path)
42+
new_routes = existing_routes.sub("draw do\n", "draw do\n mount DummySchema.dashboard\n")
43+
File.write(routes_path, new_routes)
44+
run_generator
45+
assert_file "config/routes.rb" do |content|
46+
refute_includes content, "mount GraphQL::Dashboard"
47+
end
48+
end
49+
50+
test "it sets up Redis, too" do
51+
run_generator(["--redis"])
52+
assert_no_migration "db/migrate/create_graphql_detailed_traces"
53+
assert_file "app/graphql/dummy_schema.rb" do |content|
54+
assert_includes content, "use GraphQL::Tracing::DetailedTrace, redis: raise(\"TODO: pass a connection to a persistent redis database\"), limit: 50\n\n"
55+
end
56+
57+
assert_file "config/routes.rb" do |content|
58+
assert_includes content, "mount GraphQL::Dashboard, at: \"/graphql/dashboard\", schema: \"DummySchema\""
59+
end
60+
end
61+
end

spec/support/active_record_setup.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@
107107
t.integer :points, null: false
108108
t.decimal :rating, null: false
109109
end
110+
111+
create_table :graphql_detailed_traces, force: true do |t|
112+
t.integer :begin_ms
113+
t.float :duration_ms
114+
t.text :trace_data
115+
t.string :operation_name
116+
end
110117
end
111118

112119
class Food < ActiveRecord::Base

0 commit comments

Comments
 (0)