@el4cteo/rbx-studio-mcp 0.7.7 → 0.7.8
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.
- package/dist/tools/perf.js +35 -13
- package/dist/tools/perf.js.map +1 -1
- package/dist/tools/playtest.js +34 -13
- package/dist/tools/playtest.js.map +1 -1
- package/package.json +1 -1
- package/plugin/src/ClientRelay.luau +128 -1
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/LogBuffer.luau +356 -277
- package/plugin/src/handlers/Perf.luau +913 -889
- package/plugin/src/handlers/Playtest.luau +6 -1
- package/plugin/src/init.server.luau +3 -0
- package/scripts/test-live.mjs +106 -1
- package/scripts/test-plugin.mjs +2 -0
- package/scripts/test-tools.mjs +55 -1
|
@@ -1,277 +1,356 @@
|
|
|
1
|
-
--!strict
|
|
2
|
-
--[[
|
|
3
|
-
The output log, kept ourselves.
|
|
4
|
-
|
|
5
|
-
`LogService:GetLogHistory()` looks like the right API and cannot be relied on.
|
|
6
|
-
Measured: in an editor session it returns a handful of lines from around
|
|
7
|
-
startup and then stops growing -- prints made later never appear -- and in a
|
|
8
|
-
playtest server's DataModel it succeeds and returns nothing at all, even
|
|
9
|
-
immediately after something printed there. The call reports no error either
|
|
10
|
-
way, so the tool built on it answered "the log is empty" for a place that was
|
|
11
|
-
logging steadily, which is worse than answering nothing.
|
|
12
|
-
|
|
13
|
-
`LogService.MessageOut` does fire reliably in both. So the buffer is filled
|
|
14
|
-
from the event, seeded once from whatever history the API does offer, and kept
|
|
15
|
-
from growing without bound.
|
|
16
|
-
|
|
17
|
-
This has to be connected at plugin load rather than when a read arrives:
|
|
18
|
-
anything logged before the first subscription is gone for good.
|
|
19
|
-
]]
|
|
20
|
-
|
|
21
|
-
local LogService = game:GetService("LogService")
|
|
22
|
-
local ScriptContext = game:GetService("ScriptContext")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
--
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
--
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[Enum.MessageType.
|
|
50
|
-
[Enum.MessageType.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
local
|
|
56
|
-
|
|
57
|
-
local
|
|
58
|
-
local
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
local
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
local
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1
|
+
--!strict
|
|
2
|
+
--[[
|
|
3
|
+
The output log, kept ourselves.
|
|
4
|
+
|
|
5
|
+
`LogService:GetLogHistory()` looks like the right API and cannot be relied on.
|
|
6
|
+
Measured: in an editor session it returns a handful of lines from around
|
|
7
|
+
startup and then stops growing -- prints made later never appear -- and in a
|
|
8
|
+
playtest server's DataModel it succeeds and returns nothing at all, even
|
|
9
|
+
immediately after something printed there. The call reports no error either
|
|
10
|
+
way, so the tool built on it answered "the log is empty" for a place that was
|
|
11
|
+
logging steadily, which is worse than answering nothing.
|
|
12
|
+
|
|
13
|
+
`LogService.MessageOut` does fire reliably in both. So the buffer is filled
|
|
14
|
+
from the event, seeded once from whatever history the API does offer, and kept
|
|
15
|
+
from growing without bound.
|
|
16
|
+
|
|
17
|
+
This has to be connected at plugin load rather than when a read arrives:
|
|
18
|
+
anything logged before the first subscription is gone for good.
|
|
19
|
+
]]
|
|
20
|
+
|
|
21
|
+
local LogService = game:GetService("LogService")
|
|
22
|
+
local ScriptContext = game:GetService("ScriptContext")
|
|
23
|
+
local HttpService = game:GetService("HttpService")
|
|
24
|
+
|
|
25
|
+
-- Roughly what the Output window keeps. Big enough that a playtest's worth of
|
|
26
|
+
-- logging survives, small enough to stay cheap to hold and to scan.
|
|
27
|
+
local CAPACITY = 2000
|
|
28
|
+
|
|
29
|
+
-- Trimming in batches rather than one entry per insert: a chunk that logs in a
|
|
30
|
+
-- loop would otherwise shift the whole array on every single line.
|
|
31
|
+
local SLACK = 500
|
|
32
|
+
|
|
33
|
+
-- How far back an arriving stack trace will look for the error line it belongs
|
|
34
|
+
-- to. The two events fire within moments of each other, so anything beyond a
|
|
35
|
+
-- handful of entries is a mismatch rather than a late delivery.
|
|
36
|
+
local CORRELATION_WINDOW = 20
|
|
37
|
+
|
|
38
|
+
export type Entry = {
|
|
39
|
+
sequence: number?,
|
|
40
|
+
level: string,
|
|
41
|
+
message: string,
|
|
42
|
+
timestamp: number,
|
|
43
|
+
-- Present on errors once the matching stack trace has been correlated.
|
|
44
|
+
stack: string?,
|
|
45
|
+
source: string?,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
local LEVELS: { [number]: string } = {
|
|
49
|
+
[Enum.MessageType.MessageOutput.Value] = "print",
|
|
50
|
+
[Enum.MessageType.MessageInfo.Value] = "info",
|
|
51
|
+
[Enum.MessageType.MessageWarning.Value] = "warning",
|
|
52
|
+
[Enum.MessageType.MessageError.Value] = "error",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
local LogBuffer = {}
|
|
56
|
+
|
|
57
|
+
local entries: { Entry } = {}
|
|
58
|
+
local dropped = 0
|
|
59
|
+
local started = false
|
|
60
|
+
local startedAt = 0
|
|
61
|
+
local studioToken = HttpService:GenerateGUID(false)
|
|
62
|
+
local nextSequence = 0
|
|
63
|
+
local clientBuffers: { [Player]: { token: string, entries: { Entry }, dropped: number, sequence: number, startedAt: number, ready: boolean, orphan: { [string]: { stack: string, source: string? } } } } = {}
|
|
64
|
+
|
|
65
|
+
--[[
|
|
66
|
+
Stack traces that arrived before the error line they describe.
|
|
67
|
+
|
|
68
|
+
`ScriptContext.Error` and `LogService.MessageOut` are independent events for
|
|
69
|
+
the same failure and there is no guaranteed order between them, so the trace
|
|
70
|
+
is held here until its line shows up rather than being dropped for arriving
|
|
71
|
+
first.
|
|
72
|
+
]]
|
|
73
|
+
local orphanedStacks: { [string]: { stack: string, source: string? } } = {}
|
|
74
|
+
local orphanCount = 0
|
|
75
|
+
|
|
76
|
+
--[[
|
|
77
|
+
The engine also writes each trace to the log as plain lines -- "Stack Begin",
|
|
78
|
+
one "Script 'X', Line N" per frame, then "Stack End" -- which is how the
|
|
79
|
+
Output window renders it. Having attached the trace to its error already,
|
|
80
|
+
echoing those lines as ordinary entries is duplication that reads like
|
|
81
|
+
unrelated output sitting under the failure.
|
|
82
|
+
|
|
83
|
+
They are collected instead of stored, and serve as the fallback source of a
|
|
84
|
+
trace when ScriptContext.Error is unavailable.
|
|
85
|
+
]]
|
|
86
|
+
local STACK_OPEN = "Stack Begin"
|
|
87
|
+
local STACK_CLOSE = "Stack End"
|
|
88
|
+
|
|
89
|
+
-- If a close never arrives -- a place that prints "Stack Begin" itself -- the
|
|
90
|
+
-- collected lines are released rather than swallowed indefinitely.
|
|
91
|
+
local MAX_STACK_LINES = 60
|
|
92
|
+
|
|
93
|
+
local collecting: { string }? = nil
|
|
94
|
+
|
|
95
|
+
local function pushEntry(entry: Entry)
|
|
96
|
+
nextSequence += 1
|
|
97
|
+
entry.sequence = nextSequence
|
|
98
|
+
table.insert(entries, entry)
|
|
99
|
+
|
|
100
|
+
if #entries > CAPACITY + SLACK then
|
|
101
|
+
local excess = #entries - CAPACITY
|
|
102
|
+
entries = table.move(entries, excess + 1, #entries, 1, {})
|
|
103
|
+
dropped += excess
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
-- Each player gets a separate stream and token. Rejoining, reconnecting to a
|
|
108
|
+
-- different server DataModel, or a plugin reload invalidates old cursors.
|
|
109
|
+
function LogBuffer.startClient(player: Player)
|
|
110
|
+
clientBuffers[player] = { token = HttpService:GenerateGUID(false), entries = {}, dropped = 0, sequence = 0, startedAt = os.time(), ready = false, orphan = {} }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
function LogBuffer.stopClient(player: Player)
|
|
114
|
+
clientBuffers[player] = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
function LogBuffer.pushClient(player: Player, payload: any)
|
|
118
|
+
local buffer = clientBuffers[player]
|
|
119
|
+
if not buffer or typeof(payload) ~= "table" then return end
|
|
120
|
+
if payload.kind == "ready" then buffer.ready = true; return end
|
|
121
|
+
if payload.kind == "trace" then
|
|
122
|
+
if typeof(payload.message) ~= "string" or typeof(payload.stack) ~= "string" then return end
|
|
123
|
+
for index = #buffer.entries, math.max(1, #buffer.entries - CORRELATION_WINDOW + 1), -1 do
|
|
124
|
+
local item = buffer.entries[index]
|
|
125
|
+
if item.level == "error" and item.message == payload.message and item.stack == nil then
|
|
126
|
+
item.stack = string.sub(payload.stack, 1, 4000)
|
|
127
|
+
item.source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
local orphanCount = 0
|
|
132
|
+
for _ in buffer.orphan do orphanCount += 1 end
|
|
133
|
+
if orphanCount < 20 or buffer.orphan[payload.message] ~= nil then
|
|
134
|
+
buffer.orphan[payload.message] = { stack = string.sub(payload.stack, 1, 4000), source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil }
|
|
135
|
+
end
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
local level = payload.level
|
|
139
|
+
if level ~= "print" and level ~= "info" and level ~= "warning" and level ~= "error" then return end
|
|
140
|
+
if typeof(payload.message) ~= "string" then return end
|
|
141
|
+
buffer.sequence += 1
|
|
142
|
+
local entry: Entry = {
|
|
143
|
+
sequence = buffer.sequence,
|
|
144
|
+
level = level,
|
|
145
|
+
message = string.sub(payload.message, 1, 2000),
|
|
146
|
+
timestamp = if typeof(payload.timestamp) == "number" and payload.timestamp >= 0 and payload.timestamp < 100000000000 then payload.timestamp else os.time(),
|
|
147
|
+
stack = if typeof(payload.stack) == "string" then string.sub(payload.stack, 1, 4000) else nil,
|
|
148
|
+
source = if typeof(payload.source) == "string" then string.sub(payload.source, 1, 500) else nil,
|
|
149
|
+
}
|
|
150
|
+
local waiting = buffer.orphan[entry.message]
|
|
151
|
+
if level == "error" and waiting then
|
|
152
|
+
entry.stack, entry.source = waiting.stack, waiting.source
|
|
153
|
+
buffer.orphan[entry.message] = nil
|
|
154
|
+
end
|
|
155
|
+
table.insert(buffer.entries, entry)
|
|
156
|
+
if #buffer.entries > CAPACITY + SLACK then
|
|
157
|
+
local excess = #buffer.entries - CAPACITY
|
|
158
|
+
buffer.entries = table.move(buffer.entries, excess + 1, #buffer.entries, 1, {})
|
|
159
|
+
buffer.dropped += excess
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
function LogBuffer.clientReady(player: Player): boolean
|
|
164
|
+
local buffer = clientBuffers[player]
|
|
165
|
+
return buffer ~= nil and buffer.ready == true
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
function LogBuffer.stream(player: Player?): ({ Entry }, number, string, number, number)
|
|
169
|
+
if player then
|
|
170
|
+
local buffer = clientBuffers[player]
|
|
171
|
+
if buffer then
|
|
172
|
+
return table.move(buffer.entries, 1, #buffer.entries, 1, {}), buffer.dropped, buffer.token, buffer.sequence, math.max(0, os.time() - buffer.startedAt)
|
|
173
|
+
end
|
|
174
|
+
return {}, 0, "", 0, 0
|
|
175
|
+
end
|
|
176
|
+
return table.move(entries, 1, #entries, 1, {}), dropped, studioToken, nextSequence, LogBuffer.recordingSeconds()
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
local function push(message: string, messageType: Enum.MessageType, timestamp: number?)
|
|
180
|
+
local level = LEVELS[messageType.Value] or "print"
|
|
181
|
+
local entry: Entry = {
|
|
182
|
+
level = level,
|
|
183
|
+
message = message,
|
|
184
|
+
timestamp = timestamp or os.time(),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
-- Claim a trace that beat its own error line here.
|
|
188
|
+
if level == "error" then
|
|
189
|
+
local waiting = orphanedStacks[message]
|
|
190
|
+
if waiting then
|
|
191
|
+
entry.stack = waiting.stack
|
|
192
|
+
entry.source = waiting.source
|
|
193
|
+
orphanedStacks[message] = nil
|
|
194
|
+
orphanCount -= 1
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
pushEntry(entry)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
--[[
|
|
202
|
+
Attaches a stack trace to the error it belongs to.
|
|
203
|
+
|
|
204
|
+
Correlated by message text because the engine offers nothing better: the two
|
|
205
|
+
events carry no shared identifier. Matching the most recent unattached error
|
|
206
|
+
with the same text is right in every case that matters, and the worst outcome
|
|
207
|
+
if two identical errors interleave is that a trace lands on the wrong one of
|
|
208
|
+
two identical lines.
|
|
209
|
+
]]
|
|
210
|
+
local function attachStack(message: string, stack: string, source: string?)
|
|
211
|
+
local first = math.max(1, #entries - CORRELATION_WINDOW + 1)
|
|
212
|
+
for index = #entries, first, -1 do
|
|
213
|
+
local entry = entries[index]
|
|
214
|
+
if entry.level == "error" and entry.message == message and entry.stack == nil then
|
|
215
|
+
entry.stack = stack
|
|
216
|
+
entry.source = source
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
-- Not logged yet. Held for the line to claim, with a cap so a flood of
|
|
222
|
+
-- errors that never surface cannot grow this without bound.
|
|
223
|
+
if orphanCount < CAPACITY then
|
|
224
|
+
if orphanedStacks[message] == nil then
|
|
225
|
+
orphanCount += 1
|
|
226
|
+
end
|
|
227
|
+
orphanedStacks[message] = { stack = stack, source = source }
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
--[[
|
|
232
|
+
Begins recording. Safe to call more than once; only the first call connects,
|
|
233
|
+
so a reload cannot end up with two subscriptions writing every line twice.
|
|
234
|
+
]]
|
|
235
|
+
function LogBuffer.start()
|
|
236
|
+
if started then
|
|
237
|
+
return
|
|
238
|
+
end
|
|
239
|
+
started = true
|
|
240
|
+
startedAt = os.time()
|
|
241
|
+
|
|
242
|
+
-- Seeded before subscribing so the backlog stays in order ahead of live
|
|
243
|
+
-- lines. MessageOut only fires for messages after the connection, so nothing
|
|
244
|
+
-- is counted twice.
|
|
245
|
+
local ok, history = pcall(function()
|
|
246
|
+
return LogService:GetLogHistory()
|
|
247
|
+
end)
|
|
248
|
+
if ok and typeof(history) == "table" then
|
|
249
|
+
for _, item in history :: { { [string]: any } } do
|
|
250
|
+
local kind = item.messageType
|
|
251
|
+
if item.message ~= nil and typeof(kind) == "EnumItem" then
|
|
252
|
+
push(tostring(item.message), kind :: Enum.MessageType, item.timestamp)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
LogService.MessageOut:Connect(function(message: string, messageType: Enum.MessageType)
|
|
258
|
+
local buffered = collecting
|
|
259
|
+
|
|
260
|
+
if buffered then
|
|
261
|
+
if message == STACK_CLOSE then
|
|
262
|
+
collecting = nil
|
|
263
|
+
--[[
|
|
264
|
+
ScriptContext.Error is preferred -- it names the originating
|
|
265
|
+
instance too -- but not blindly. Measured on a nested failure
|
|
266
|
+
the two agree exactly, one frame each, so folding loses
|
|
267
|
+
nothing today. They are still compared by frame count rather
|
|
268
|
+
than trusted to stay equal, because the cost of being wrong
|
|
269
|
+
is a silently shallower trace and the check is one compare.
|
|
270
|
+
]]
|
|
271
|
+
if #buffered > 0 then
|
|
272
|
+
local recent = entries[#entries]
|
|
273
|
+
if recent and recent.level == "error" then
|
|
274
|
+
local existing = recent.stack
|
|
275
|
+
local existingFrames = if existing then #string.split(existing, "\n") else 0
|
|
276
|
+
if existing == nil or #buffered > existingFrames then
|
|
277
|
+
recent.stack = table.concat(buffered, "\n")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
return
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
if #buffered < MAX_STACK_LINES then
|
|
285
|
+
table.insert(buffered, message)
|
|
286
|
+
return
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
-- Not a stack after all. Release what was held, in order, and carry
|
|
290
|
+
-- on treating this as ordinary output.
|
|
291
|
+
collecting = nil
|
|
292
|
+
pushEntry({ level = "print", message = STACK_OPEN, timestamp = os.time() })
|
|
293
|
+
for _, held in buffered do
|
|
294
|
+
pushEntry({ level = "print", message = held, timestamp = os.time() })
|
|
295
|
+
end
|
|
296
|
+
elseif message == STACK_OPEN then
|
|
297
|
+
collecting = {}
|
|
298
|
+
return
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
push(message, messageType)
|
|
302
|
+
end)
|
|
303
|
+
|
|
304
|
+
--[[
|
|
305
|
+
Stack traces, which MessageOut does not carry.
|
|
306
|
+
|
|
307
|
+
Without this an error in the log is a message and nothing else, while the
|
|
308
|
+
trace arrives separately as loose "Stack Begin / Script X, Line N / Stack
|
|
309
|
+
End" prints that read as unrelated output. Attaching it to the error turns
|
|
310
|
+
three ambiguous lines into one answer with a script and a line number.
|
|
311
|
+
|
|
312
|
+
`ErrorDetailed` would be the richer source and is closed to plugins --
|
|
313
|
+
it needs the RobloxScript capability. `Error` is reachable and carries
|
|
314
|
+
message, trace and originating script, which is the part that matters.
|
|
315
|
+
]]
|
|
316
|
+
pcall(function()
|
|
317
|
+
ScriptContext.Error:Connect(function(message: string, stack: string, source: Instance?)
|
|
318
|
+
local origin: string? = nil
|
|
319
|
+
if typeof(source) == "Instance" then
|
|
320
|
+
local named = pcall(function()
|
|
321
|
+
origin = source:GetFullName()
|
|
322
|
+
end)
|
|
323
|
+
if not named then
|
|
324
|
+
origin = source.Name
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
attachStack(message, stack, origin)
|
|
328
|
+
end)
|
|
329
|
+
end)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
--[[
|
|
333
|
+
Everything held, oldest first, plus how much fell off the front. Returned as a
|
|
334
|
+
copy so a caller filtering it cannot disturb the buffer.
|
|
335
|
+
]]
|
|
336
|
+
function LogBuffer.all(): ({ Entry }, number)
|
|
337
|
+
return table.move(entries, 1, #entries, 1, {}), dropped
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
--[[
|
|
341
|
+
How long this buffer has been recording, in seconds.
|
|
342
|
+
|
|
343
|
+
An empty log is ambiguous without it. In a playtest the plugin loads at the
|
|
344
|
+
same moment as the place's own scripts, so a script that logs on startup can
|
|
345
|
+
beat the subscription -- and "nothing was logged" then looks identical to
|
|
346
|
+
"recording began after the thing you are asking about". Reporting the window
|
|
347
|
+
lets the two be told apart instead of guessed at.
|
|
348
|
+
]]
|
|
349
|
+
function LogBuffer.recordingSeconds(): number
|
|
350
|
+
if not started then
|
|
351
|
+
return 0
|
|
352
|
+
end
|
|
353
|
+
return math.max(0, os.time() - startedAt)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
return LogBuffer
|