Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
76dad36
Add Rails engine-based dashboard with Perfetto trace listing
rmosolgo Feb 19, 2025
5037617
Add memory and redis backends
rmosolgo Feb 19, 2025
6ef72dc
Add icons and dark mode
rmosolgo Feb 19, 2025
a72de98
update tests, statics
rmosolgo Feb 19, 2025
96470ee
Add tests for memory and redis storage
rmosolgo Feb 19, 2025
93ff323
improve Redis CI setup
rmosolgo Feb 19, 2025
68129a9
Add PerfettoSampler tests
rmosolgo Feb 19, 2025
d020c1d
Add test coverage
rmosolgo Feb 19, 2025
1cf44ac
Fix a couple of tests
rmosolgo Feb 19, 2025
e15a9d4
Try running system tests with a ci gemfile
rmosolgo Feb 20, 2025
652b38a
Try different gemfile setup
rmosolgo Feb 20, 2025
d556cd0
try bin/rails
rmosolgo Feb 20, 2025
2245b02
Try relative gemfile path
rmosolgo Feb 20, 2025
ad24c1c
Merge branch 'master' into dashboard-engine
rmosolgo Feb 20, 2025
56113c9
Merge branch 'master' into dashboard-engine
rmosolgo Feb 20, 2025
06db592
Clear any leftover flow stack
rmosolgo Feb 21, 2025
1bd193e
Merge branch 'master' into dashboard-engine
rmosolgo Feb 21, 2025
1d11253
Add CSP rules
rmosolgo Feb 22, 2025
735eed9
Start adding tests
rmosolgo Feb 24, 2025
86b526e
Add tests for Dashboard controllers
rmosolgo Feb 24, 2025
bf7cf65
Update Redis backend
rmosolgo Feb 24, 2025
ae7eceb
Add limit: option
rmosolgo Feb 24, 2025
9de189a
Update error message test
rmosolgo Feb 24, 2025
3b5566a
fix dashboard tests
rmosolgo Feb 24, 2025
ace88db
Rename to DetailedTrace, write docs
rmosolgo Feb 24, 2025
c5415bf
Add test for multiplex
rmosolgo Feb 24, 2025
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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ jobs:
ruby: 3.4
graphql_reject_numbers_followed_by_names: 1
isolation_level_fiber: 1
redis: 1
runs-on: ubuntu-latest
steps:
- run: echo BUNDLE_GEMFILE=${{ matrix.gemfile }} > $GITHUB_ENV
- run: echo GRAPHQL_REJECT_NUMBERS_FOLLOWED_BY_NAMES=1 > $GITHUB_ENV
if: ${{ !!matrix.graphql_reject_numbers_followed_by_names }}
- run: echo ISOLATION_LEVEL_FIBER=1 > $GITHUB_ENV
if: ${{ !!matrix.isolation_level_fiber }}
- uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
if: ${{ !!matrix.redis }}
- run: redis-cli ping
if: ${{ !!matrix.redis }}
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
Expand Down
1 change: 1 addition & 0 deletions gemfiles/rails_master.gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ if RUBY_ENGINE == "ruby" # This doesn't work on truffle-ruby because there's no
end
gem "async"
gem "google-protobuf"
gem "redis"

gemspec path: "../"
3 changes: 3 additions & 0 deletions lib/graphql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ class << self
autoload :LoadApplicationObjectFailedError, "graphql/load_application_object_failed_error"
autoload :Testing, "graphql/testing"
autoload :Current, "graphql/current"
if defined?(::Rails::Engine)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

module GraphQL missing tests for lines 15, 16, 17, 18, 63, 67, 68, 72, 128, 128, 128 (coverage: 0.88)

autoload :Dashboard, 'graphql/dashboard'
end
end

require "graphql/version"
Expand Down
76 changes: 76 additions & 0 deletions lib/graphql/dashboard.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true
require 'rails/engine'

module Graphql
class Dashboard < Rails::Engine
engine_name "graphql_dashboard"
isolate_namespace(Graphql::Dashboard)
routes.draw do
root "landings#show"
resources :statics, only: :show, constraints: { id: /[0-9A-Za-z\-.]+/ }
resources :traces, only: [:index, :show, :destroy]
end

class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
prepend_view_path(File.join(__FILE__, "../dashboard/views"))

def schema_class
@schema_class ||= case params[:schema]
when Class
params[:schema]
when String
params[:schema].constantize
else
raise "Missing `params[:schema]`, please provide a class or string to `mount GraphQL::Dashboard, schema: ...`"
end
end
helper_method :schema_class
end

class LandingsController < ApplicationController
def show
end
end

class TracesController < ApplicationController
def index
@perfetto_sampler = schema_class.perfetto_sampler
end

def show
trace = schema_class.perfetto_sampler.find_trace(params[:id].to_i)
send_data(trace.trace_data)
end

def destroy
schema_class.perfetto_sampler.delete_trace(params[:id])
head :no_content
end
end

class StaticsController < ApplicationController
# Use an explicit list of files to avoid any chance of reading other files from disk
STATICS = {}

[
"icon.png",
"header-icon.png"
].each do |static_file|
STATICS[static_file] = File.expand_path("../dashboard/statics/#{static_file}", __FILE__)
end

def show
expires_in 1.year, public: true
if (filepath = STATICS[params[:id]])
render file: filepath
else
head :no_content
end
end
end
end
end


GraphQL::Dashboard = Graphql::Dashboard
Binary file added lib/graphql/dashboard/statics/header-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lib/graphql/dashboard/statics/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<% content_for(:title, "Landing") %>

<div class="row">
<div class="col-md col-lg-8 mx-auto pt-4">
<div class="card mt-4">
<div class="card-body">
<div class="card-title">
<h2>
Welcome to the GraphQL-Ruby Dashboard
</h2>
</div>
<p class="card-text">
Click the links above to see data about your schema (<code><%= schema_class %></code>).
</p>
</div>
</div>
</div>
</div>
103 changes: 103 additions & 0 deletions lib/graphql/dashboard/views/graphql/dashboard/traces/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<% content_for(:title, "Traces") %>
<div class="row">
<div class="col">
<h1>Perfetto Traces</h1>
</div>
</div>
<% if !@perfetto_sampler %>
<div class="row">
<div class="col-md col-lg-6 mx-auto">
<div class="card mt-4">
<div class="card-body">
<div class="card-title">
<h3>
Traces aren't installed yet
</h3>
</div>
<p class="card-text">
GraphQL-Ruby can instrument production traffic and save tracing artifacts here for later review.
</p>
<p class="card-text">
Read more in <%= link_to "the tracing docs", "#" %>.
</p>
</div>
</div>
</div>
</div>
<% else %>
<div class="row">
<div class="col">
<% perfetto_traces = @perfetto_sampler.traces %>
<table class="table table-striped">
<thead>
<tr>
<th>Operation</th>
<th>Duration (ms) </th>
<th>Timestamp</th>
<th>Open in Perfetto UI</th>
</tr>
</thead>
<tbody>
<% if perfetto_traces.empty? %>
<tr>
<td colspan="4" class="text-center">
<em>No traces saved yet. Read about saving traces <%= link_to "in the docs", "#" %>.</em>
</td>
</tr>
<% end %>
<% perfetto_traces.each do |trace| %>
<tr>
<td><%= trace.operation_name %></td>
<td><%= trace.duration_ms.round(2) %></td>
<td><%= trace.timestamp %></td>
<td><%= link_to "View ↗", "#", onclick: "openOnPerfetto('#{trace.operation_name}', '#{graphql_dashboard.traces_path}/#{trace.id}')" %></td>
<td><%= link_to "Delete", "#", onclick: "deleteTrace('#{graphql_dashboard.traces_path}/#{trace.id}', event)", class: "text-danger" %></td>
</tr>
<% end %>
</tbody>
</table>

<script>
var perfettoUrl = "https://ui.perfetto.dev"
async function openOnPerfetto(operationName, tracePath) {
var resp = await fetch(tracePath);
var blob = await resp.blob();
var nextPerfettoData = await blob.arrayBuffer();
nextPerfettoWindow = window.open(perfettoUrl)

var messageHandler = function(event) {
if (event.origin == perfettoUrl && event.data == "PONG") {
clearInterval(perfettoWaiting)
window.removeEventListener("message", messageHandler)
nextPerfettoWindow.postMessage({
'perfetto': {
buffer: nextPerfettoData,
title: "GraphQL: " + operationName,
}
}, perfettoUrl)
}
}

window.addEventListener("message", messageHandler, false)
perfettoWaiting = setInterval(function() {
nextPerfettoWindow.postMessage("PING", perfettoUrl)
}, 100)
}

async function deleteTrace(tracePath, event) {
if (confirm("Are you sure you want to permanently delete this trace?")) {
var response = await fetch(tracePath, { method: "DELETE", headers: {
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content
} })
if (response.ok) {
var row = event.target.closest("tr")
row.remove()
} else {
console.error("Delete request failed for", tracePath, response)
}
}
}
</script>
</div>
</div>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en" class="h-100" >
<head>
<link rel="icon" type="image/png" href="<%= graphql_dashboard.static_path("icon.png") %>" />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GraphQL Dashboard <%= content_for?(:title) ? " · #{content_for(:title)}" : "" %> </title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<%= csrf_meta_tags %>
<script>
function detectTheme() {
var storedTheme = localStorage.getItem("graphql_dashboard:theme")
var preferredTheme = !!window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light"
setTheme(storedTheme || preferredTheme)
}

function toggleTheme() {
var nextTheme = document.documentElement.getAttribute("data-bs-theme") == "dark" ? "light" : "dark"
setTheme(nextTheme)
}

function setTheme(theme) {
localStorage.setItem("graphql_dashboard:theme", theme)
document.documentElement.setAttribute("data-bs-theme", theme)
var icon = theme == "dark" ? "🌙" : "🌞"
document.getElementById("themeToggle").innerText = icon
}
</script>

</head>
<body class="h-100 d-flex flex-column">
<main class="flex-shrink-0">
<div class="container-fluid">
<div class="row">
<div class="col gx-0">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<%= link_to graphql_dashboard.root_path, class: "navbar-brand" do %>
<img src="<%= graphql_dashboard.static_path("header-icon.png") %>" alt="GraphQL-Ruby" style="max-height: 2em" />
<% end %>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<%= link_to "Traces", graphql_dashboard.traces_path, class: "nav-link #{params[:controller] == "graphql/dashboard/traces" ? "active" : ""}" %>
</li>
</ul>
<span class="navbar-text pe-2">
<%= link_to ".", "#", onclick: "toggleTheme()", class: "nav-link", id: "themeToggle" %>
</span>
</div>
</div>
</nav>
</div>
</div>
</div>
<script>detectTheme()</script>
<div class="container-fluid">
<%= yield %>
</div>
</main>
<footer class="mt-auto">
<div class="container-fluid">
<div class="sticky-bottom">
<div class="row bg-body-tertiary">
<div class="col gx-0 px-4">
<p class="fs-6 text-center pt-2 text-muted">
<em>GraphQL-Ruby v<%= GraphQL::VERSION %></em> · <code><%= schema_class %></code>
</p>
</div>
</div>
</div>
</div>
</footer>
</body>
</html>
2 changes: 2 additions & 0 deletions lib/graphql/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,8 @@ def default_directives
}.freeze
end

attr_accessor :perfetto_sampler

def tracer(new_tracer, silence_deprecation_warning: false)
if !silence_deprecation_warning
warn("`Schema.tracer(#{new_tracer.inspect})` is deprecated; use module-based `trace_with` instead. See: https://graphql-ruby.org/queries/tracing.html")
Expand Down
1 change: 1 addition & 0 deletions lib/graphql/tracing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ module Tracing
autoload :StatsdTrace, "graphql/tracing/statsd_trace"
autoload :PrometheusTrace, "graphql/tracing/prometheus_trace"
autoload :PerfettoTrace, "graphql/tracing/perfetto_trace"
autoload :PerfettoSampler, "graphql/tracing/perfetto_sampler"

# Objects may include traceable to gain a `.trace(...)` method.
# The object must have a `@tracers` ivar of type `Array<<#trace(k, d, &b)>>`.
Expand Down
55 changes: 55 additions & 0 deletions lib/graphql/tracing/perfetto_sampler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "graphql/tracing/perfetto_sampler/memory_backend"
require "graphql/tracing/perfetto_sampler/redis_backend"

module GraphQL
module Tracing
class PerfettoSampler
def self.use(schema, trace_mode: :perfetto_sample, memory: false, redis: nil, active_record: true)
storage = if redis
RedisBackend.new(redis: redis)
elsif memory
MemoryBackend.new
elsif active_record != false
ActiveRecordBackend.new
else
raise ArgumentError, "A storage option must be chosen"
end
schema.perfetto_sampler = self.new(storage: storage)
schema.trace_with(PerfettoTrace, mode: trace_mode, save_trace_mode: trace_mode)
end

def initialize(storage:)
@storage = storage
end

def save_trace(operation_name, duration_ms, timestamp, trace_data)
@storage.save_trace(operation_name, duration_ms, timestamp, trace_data)
end

def traces
@storage.traces
end

def find_trace(id)
@storage.find_trace(id)
end

def delete_trace(id)
@storage.delete_trace(id)
end

class StoredTrace
def initialize(id:, operation_name:, duration_ms:, timestamp:, trace_data:)
@id = id
@operation_name = operation_name
@duration_ms = duration_ms
@timestamp = timestamp
@trace_data = trace_data
end

attr_reader :id, :operation_name, :duration_ms, :timestamp, :trace_data
end
end
end
end
Loading
Loading