rails-profiler 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/profiler/instrumentation/active_job_instrumentation.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +7 -0
- data/lib/profiler/mcp/body_formatter.rb +50 -0
- data/lib/profiler/mcp/file_cache.rb +31 -0
- data/lib/profiler/mcp/path_extractor.rb +30 -0
- data/lib/profiler/mcp/server.rb +30 -12
- data/lib/profiler/mcp/tools/analyze_queries.rb +46 -45
- data/lib/profiler/mcp/tools/get_profile_ajax.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_detail.rb +276 -194
- data/lib/profiler/mcp/tools/get_profile_dumps.rb +5 -1
- data/lib/profiler/mcp/tools/get_profile_http.rb +69 -21
- data/lib/profiler/mcp/tools/query_jobs.rb +37 -14
- data/lib/profiler/mcp/tools/query_profiles.rb +42 -27
- data/lib/profiler/models/profile.rb +8 -0
- data/lib/profiler/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a0b42cc0bcdfe940609dbb07f9ee29a23cd00495c738711950749b22e58d664
|
|
4
|
+
data.tar.gz: b61678855bf48c082f4776773ce1754328c4e6b6d1672ea2542a836f79d9602a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bde96bed30d2bc7fda19f62a10a2a1b9d55e1e73b83bc91ae0bba63ae7fb58a0d66ca692cb3db7ca66d17c29cfc69d190123095d6cc8f428e33e4d3030733b70
|
|
7
|
+
data.tar.gz: 310c1ddfc31ab16fddd783db87e24bbe10a5c48058f678215f389f66056ede8af928438f7a350fd4f9ab8828f49f6daf4cdffd4f41634c76b3f9d1f0a0840f0f
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "base64"
|
|
5
5
|
require "zlib"
|
|
6
6
|
require "stringio"
|
|
7
|
+
require "securerandom"
|
|
7
8
|
|
|
8
9
|
module Profiler
|
|
9
10
|
module Instrumentation
|
|
@@ -22,6 +23,8 @@ module Profiler
|
|
|
22
23
|
url = build_url(host, port, req.path, use_ssl?)
|
|
23
24
|
req_body = req.body.to_s
|
|
24
25
|
req_headers = req.to_hash.transform_values { |v| v.join(", ") }
|
|
26
|
+
request_id = SecureRandom.hex(8)
|
|
27
|
+
started_at = Time.now.iso8601(3)
|
|
25
28
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
29
|
Thread.current[:profiler_http_recording] = true
|
|
27
30
|
|
|
@@ -40,6 +43,8 @@ module Profiler
|
|
|
40
43
|
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
44
|
|
|
42
45
|
collector.record_request(
|
|
46
|
+
id: request_id,
|
|
47
|
+
started_at: started_at,
|
|
43
48
|
url: url,
|
|
44
49
|
method: req.method,
|
|
45
50
|
status: response.code.to_i,
|
|
@@ -63,6 +68,8 @@ module Profiler
|
|
|
63
68
|
if defined?(t0) && t0
|
|
64
69
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
65
70
|
collector&.record_request(
|
|
71
|
+
id: defined?(request_id) ? request_id : SecureRandom.hex(8),
|
|
72
|
+
started_at: defined?(started_at) ? started_at : Time.now.iso8601(3),
|
|
66
73
|
url: url,
|
|
67
74
|
method: req.method,
|
|
68
75
|
status: 0,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Profiler
|
|
4
|
+
module MCP
|
|
5
|
+
module BodyFormatter
|
|
6
|
+
# Formats a body string for MCP output.
|
|
7
|
+
#
|
|
8
|
+
# params keys used:
|
|
9
|
+
# "save_bodies" (boolean) — save to /tmp/rails-profiler/{token}/{name} and return path
|
|
10
|
+
# "max_body_size" (number) — truncate inline body at N chars
|
|
11
|
+
# "json_path" (string) — JSONPath to extract from body when save_bodies is true
|
|
12
|
+
# "xml_path" (string) — XPath to extract from body when save_bodies is true
|
|
13
|
+
#
|
|
14
|
+
# Returns a formatted string ready to embed in markdown, or nil if body is blank.
|
|
15
|
+
def self.format_body(token, name, body, encoding, params)
|
|
16
|
+
return " *(binary, base64 encoded)*" if encoding == "base64"
|
|
17
|
+
return nil if body.nil? || body.empty?
|
|
18
|
+
|
|
19
|
+
if params["save_bodies"]
|
|
20
|
+
path = FileCache.save(token, name, body)
|
|
21
|
+
if path
|
|
22
|
+
result = " *(saved → `#{path}`)*"
|
|
23
|
+
if params["json_path"]
|
|
24
|
+
extracted = PathExtractor.extract_json(body, params["json_path"])
|
|
25
|
+
result += "\n `#{params['json_path']}` → `#{extracted}`"
|
|
26
|
+
elsif params["xml_path"]
|
|
27
|
+
extracted = PathExtractor.extract_xml(body, params["xml_path"])
|
|
28
|
+
result += "\n `#{params['xml_path']}` → `#{extracted}`"
|
|
29
|
+
end
|
|
30
|
+
result
|
|
31
|
+
else
|
|
32
|
+
truncate_body(body, params["max_body_size"])
|
|
33
|
+
end
|
|
34
|
+
else
|
|
35
|
+
truncate_body(body, params["max_body_size"])
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.truncate_body(body, max_size)
|
|
40
|
+
max = max_size&.to_i
|
|
41
|
+
content = if max && body.length > max
|
|
42
|
+
"#{body[0, max]}\n... [truncated, #{body.length} chars total]"
|
|
43
|
+
else
|
|
44
|
+
body
|
|
45
|
+
end
|
|
46
|
+
"```\n#{content}\n```"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
class FileCache
|
|
8
|
+
BASE_DIR = "/tmp/rails-profiler"
|
|
9
|
+
|
|
10
|
+
def self.save(token, name, content)
|
|
11
|
+
cleanup if rand < 0.05
|
|
12
|
+
|
|
13
|
+
dir = File.join(BASE_DIR, token)
|
|
14
|
+
FileUtils.mkdir_p(dir)
|
|
15
|
+
path = File.join(dir, name)
|
|
16
|
+
File.write(path, content)
|
|
17
|
+
path
|
|
18
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.cleanup(max_age: 3600)
|
|
23
|
+
return unless Dir.exist?(BASE_DIR)
|
|
24
|
+
|
|
25
|
+
Dir.glob(File.join(BASE_DIR, "*")).each do |dir|
|
|
26
|
+
FileUtils.rm_rf(dir) if File.directory?(dir) && (Time.now - File.mtime(dir)) > max_age
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
module MCP
|
|
7
|
+
module PathExtractor
|
|
8
|
+
# Supports dot-notation JSONPath: $.key, $.key.sub, $.array[0].key
|
|
9
|
+
def self.extract_json(content, path)
|
|
10
|
+
data = JSON.parse(content)
|
|
11
|
+
segments = path.sub(/\A\$\.?/, "").split(".").flat_map do |seg|
|
|
12
|
+
seg =~ /\A(.+)\[(\d+)\]\z/ ? [$1, $2.to_i] : [seg]
|
|
13
|
+
end
|
|
14
|
+
result = segments.reduce(data) do |obj, seg|
|
|
15
|
+
obj.is_a?(Array) ? obj[seg.to_i] : obj[seg]
|
|
16
|
+
end
|
|
17
|
+
JSON.generate(result)
|
|
18
|
+
rescue => e
|
|
19
|
+
"JSONPath error: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.extract_xml(content, xpath)
|
|
23
|
+
require "nokogiri"
|
|
24
|
+
Nokogiri::XML(content).xpath(xpath).to_s
|
|
25
|
+
rescue => e
|
|
26
|
+
"XPath error: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -59,6 +59,9 @@ module Profiler
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
61
|
def build_tools
|
|
62
|
+
require_relative "file_cache"
|
|
63
|
+
require_relative "path_extractor"
|
|
64
|
+
require_relative "body_formatter"
|
|
62
65
|
require_relative "tools/query_profiles"
|
|
63
66
|
require_relative "tools/get_profile_detail"
|
|
64
67
|
require_relative "tools/analyze_queries"
|
|
@@ -78,17 +81,24 @@ module Profiler
|
|
|
78
81
|
method: { type: "string", description: "Filter by HTTP method (GET, POST, etc.)" },
|
|
79
82
|
min_duration: { type: "number", description: "Minimum duration in milliseconds" },
|
|
80
83
|
profile_type: { type: "string", description: "Filter by type: 'http' or 'job'" },
|
|
81
|
-
limit: { type: "number", description: "Maximum number of results" }
|
|
84
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
85
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, type, method, path, duration, queries, status, token. Omit for all." },
|
|
86
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns profiles older than this." }
|
|
82
87
|
}
|
|
83
88
|
},
|
|
84
89
|
handler: Tools::QueryProfiles
|
|
85
90
|
),
|
|
86
91
|
define_tool(
|
|
87
92
|
name: "get_profile",
|
|
88
|
-
description: "Get detailed profile data by token",
|
|
93
|
+
description: "Get detailed profile data by token. Use 'latest' as token to get the most recent profile.",
|
|
89
94
|
input_schema: {
|
|
90
95
|
properties: {
|
|
91
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
96
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
97
|
+
sections: { type: "array", items: { type: "string" }, description: "Sections to include. Valid values: overview, exception, job, request, response, curl, database, performance, views, cache, ajax, http, routes, dumps. Omit for all." },
|
|
98
|
+
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
99
|
+
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
100
|
+
json_path: { type: "string", description: "JSONPath expression to extract from response body (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
101
|
+
xml_path: { type: "string", description: "XPath expression to extract from response body (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
|
|
92
102
|
},
|
|
93
103
|
required: ["token"]
|
|
94
104
|
},
|
|
@@ -96,10 +106,11 @@ module Profiler
|
|
|
96
106
|
),
|
|
97
107
|
define_tool(
|
|
98
108
|
name: "analyze_queries",
|
|
99
|
-
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries",
|
|
109
|
+
description: "Analyze SQL queries for N+1 problems, duplicates, and slow queries. Use 'latest' as token to analyze the most recent profile.",
|
|
100
110
|
input_schema: {
|
|
101
111
|
properties: {
|
|
102
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
112
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
113
|
+
summary_only: { type: "boolean", description: "Return only the summary statistics section, skipping slow query and N+1 details." }
|
|
103
114
|
},
|
|
104
115
|
required: ["token"]
|
|
105
116
|
},
|
|
@@ -107,10 +118,10 @@ module Profiler
|
|
|
107
118
|
),
|
|
108
119
|
define_tool(
|
|
109
120
|
name: "get_profile_ajax",
|
|
110
|
-
description: "Get detailed AJAX sub-request breakdown for a profile",
|
|
121
|
+
description: "Get detailed AJAX sub-request breakdown for a profile. Use 'latest' as token to get the most recent profile.",
|
|
111
122
|
input_schema: {
|
|
112
123
|
properties: {
|
|
113
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
124
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
114
125
|
},
|
|
115
126
|
required: ["token"]
|
|
116
127
|
},
|
|
@@ -118,10 +129,10 @@ module Profiler
|
|
|
118
129
|
),
|
|
119
130
|
define_tool(
|
|
120
131
|
name: "get_profile_dumps",
|
|
121
|
-
description: "Get variable dumps captured during a profile",
|
|
132
|
+
description: "Get variable dumps captured during a profile. Use 'latest' as token to get the most recent profile.",
|
|
122
133
|
input_schema: {
|
|
123
134
|
properties: {
|
|
124
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
135
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" }
|
|
125
136
|
},
|
|
126
137
|
required: ["token"]
|
|
127
138
|
},
|
|
@@ -129,10 +140,15 @@ module Profiler
|
|
|
129
140
|
),
|
|
130
141
|
define_tool(
|
|
131
142
|
name: "get_profile_http",
|
|
132
|
-
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request)",
|
|
143
|
+
description: "Get outbound HTTP request breakdown for a profile (external API calls made during the request). Use 'latest' as token to get the most recent profile.",
|
|
133
144
|
input_schema: {
|
|
134
145
|
properties: {
|
|
135
|
-
token: { type: "string", description: "Profile token (required)" }
|
|
146
|
+
token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
|
|
147
|
+
domain: { type: "string", description: "Filter outbound requests by domain (partial match on host)" },
|
|
148
|
+
save_bodies: { type: "boolean", description: "Save request/response bodies to temp files and return paths instead of inlining content." },
|
|
149
|
+
max_body_size: { type: "number", description: "Truncate inlined body content at N characters. Ignored when save_bodies is true." },
|
|
150
|
+
json_path: { type: "string", description: "JSONPath expression to extract from response bodies (e.g. '$.data.items[0]'). Only applied when save_bodies is true." },
|
|
151
|
+
xml_path: { type: "string", description: "XPath expression to extract from response bodies (e.g. '//items/item[1]/name'). Only applied when save_bodies is true." }
|
|
136
152
|
},
|
|
137
153
|
required: ["token"]
|
|
138
154
|
},
|
|
@@ -145,7 +161,9 @@ module Profiler
|
|
|
145
161
|
properties: {
|
|
146
162
|
queue: { type: "string", description: "Filter by queue name" },
|
|
147
163
|
status: { type: "string", description: "Filter by status (completed, failed)" },
|
|
148
|
-
limit: { type: "number", description: "Maximum number of results" }
|
|
164
|
+
limit: { type: "number", description: "Maximum number of results (default 20)" },
|
|
165
|
+
fields: { type: "array", items: { type: "string" }, description: "Columns to include. Valid values: time, job_class, queue, status, duration, token. Omit for all." },
|
|
166
|
+
cursor: { type: "string", description: "Pagination cursor: ISO8601 timestamp of the last item seen. Returns jobs older than this." }
|
|
149
167
|
}
|
|
150
168
|
},
|
|
151
169
|
handler: Tools::QueryJobs
|
|
@@ -15,7 +15,11 @@ module Profiler
|
|
|
15
15
|
]
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
profile =
|
|
18
|
+
profile = if token == "latest"
|
|
19
|
+
Profiler.storage.list(limit: 1).first
|
|
20
|
+
else
|
|
21
|
+
Profiler.storage.load(token)
|
|
22
|
+
end
|
|
19
23
|
unless profile
|
|
20
24
|
return [
|
|
21
25
|
{
|
|
@@ -35,7 +39,8 @@ module Profiler
|
|
|
35
39
|
]
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
summary_only = params["summary_only"] == true || params["summary_only"] == "true"
|
|
43
|
+
text = analyze_and_format(db_data["queries"], summary_only: summary_only)
|
|
39
44
|
|
|
40
45
|
[
|
|
41
46
|
{
|
|
@@ -47,58 +52,55 @@ module Profiler
|
|
|
47
52
|
|
|
48
53
|
private
|
|
49
54
|
|
|
50
|
-
def self.analyze_and_format(queries)
|
|
55
|
+
def self.analyze_and_format(queries, summary_only: false)
|
|
51
56
|
lines = []
|
|
52
57
|
lines << "# SQL Query Analysis\n"
|
|
53
58
|
|
|
54
|
-
# Detect slow queries
|
|
55
59
|
slow_threshold = Profiler.configuration.slow_query_threshold
|
|
56
60
|
slow_queries = queries.select { |q| q["duration"] > slow_threshold }
|
|
57
|
-
|
|
58
|
-
if slow_queries.any?
|
|
59
|
-
lines << "## ⚠️ Slow Queries (> #{slow_threshold}ms)"
|
|
60
|
-
lines << "Found #{slow_queries.size} slow queries:\n"
|
|
61
|
-
|
|
62
|
-
slow_queries.first(5).each_with_index do |query, index|
|
|
63
|
-
lines << "### Query #{index + 1} - #{query['duration'].round(2)}ms"
|
|
64
|
-
lines << "```sql"
|
|
65
|
-
lines << query['sql']
|
|
66
|
-
lines << "```\n"
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
if slow_queries.size > 5
|
|
70
|
-
lines << "_... and #{slow_queries.size - 5} more slow queries_\n"
|
|
71
|
-
end
|
|
72
|
-
else
|
|
73
|
-
lines << "## ✅ No Slow Queries"
|
|
74
|
-
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Detect duplicate queries (potential N+1)
|
|
78
61
|
query_counts = queries.group_by { |q| normalize_sql(q["sql"]) }
|
|
79
62
|
.transform_values(&:count)
|
|
80
63
|
.select { |_, count| count > 1 }
|
|
81
64
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
65
|
+
unless summary_only
|
|
66
|
+
# Detect slow queries
|
|
67
|
+
if slow_queries.any?
|
|
68
|
+
lines << "## ⚠️ Slow Queries (> #{slow_threshold}ms)"
|
|
69
|
+
lines << "Found #{slow_queries.size} slow queries:\n"
|
|
70
|
+
|
|
71
|
+
slow_queries.first(5).each_with_index do |query, index|
|
|
72
|
+
lines << "### Query #{index + 1} - #{query['duration'].round(2)}ms"
|
|
73
|
+
lines << "```sql"
|
|
74
|
+
lines << query["sql"]
|
|
75
|
+
lines << "```\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
lines << "_... and #{slow_queries.size - 5} more slow queries_\n" if slow_queries.size > 5
|
|
79
|
+
else
|
|
80
|
+
lines << "## ✅ No Slow Queries"
|
|
81
|
+
lines << "All queries executed in less than #{slow_threshold}ms\n"
|
|
91
82
|
end
|
|
92
83
|
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
# Detect duplicate queries (potential N+1)
|
|
85
|
+
if query_counts.any?
|
|
86
|
+
lines << "## ⚠️ Duplicate Queries (Potential N+1)"
|
|
87
|
+
lines << "Found #{query_counts.size} duplicate query patterns:\n"
|
|
88
|
+
|
|
89
|
+
query_counts.sort_by { |_, count| -count }.first(5).each do |sql, count|
|
|
90
|
+
lines << "### Executed #{count} times:"
|
|
91
|
+
lines << "```sql"
|
|
92
|
+
lines << sql
|
|
93
|
+
lines << "```\n"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
lines << "_... and #{query_counts.size - 5} more duplicate patterns_\n" if query_counts.size > 5
|
|
97
|
+
else
|
|
98
|
+
lines << "## ✅ No Duplicate Queries"
|
|
99
|
+
lines << "No potential N+1 query problems detected\n"
|
|
95
100
|
end
|
|
96
|
-
else
|
|
97
|
-
lines << "## ✅ No Duplicate Queries"
|
|
98
|
-
lines << "No potential N+1 query problems detected\n"
|
|
99
101
|
end
|
|
100
102
|
|
|
101
|
-
#
|
|
103
|
+
# Summary statistics always included
|
|
102
104
|
lines << "## Summary Statistics"
|
|
103
105
|
lines << "- **Total Queries:** #{queries.size}"
|
|
104
106
|
lines << "- **Total Duration:** #{queries.sum { |q| q['duration'] }.round(2)}ms"
|
|
@@ -111,11 +113,10 @@ module Profiler
|
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def self.normalize_sql(sql)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
.gsub(
|
|
117
|
-
.gsub(/
|
|
118
|
-
.gsub(/"[^"]*"/, '?')
|
|
116
|
+
sql.gsub(/\$\d+/, "?")
|
|
117
|
+
.gsub(/\b\d+\b/, "?")
|
|
118
|
+
.gsub(/'[^']*'/, "?")
|
|
119
|
+
.gsub(/"[^"]*"/, "?")
|
|
119
120
|
.strip
|
|
120
121
|
end
|
|
121
122
|
end
|
|
@@ -10,7 +10,11 @@ module Profiler
|
|
|
10
10
|
return [{ type: "text", text: "Error: token parameter is required" }]
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
profile =
|
|
13
|
+
profile = if token == "latest"
|
|
14
|
+
Profiler.storage.list(limit: 1).first
|
|
15
|
+
else
|
|
16
|
+
Profiler.storage.load(token)
|
|
17
|
+
end
|
|
14
18
|
unless profile
|
|
15
19
|
return [{ type: "text", text: "Profile not found: #{token}" }]
|
|
16
20
|
end
|