@reventlessdev/reventless-local 3.0.0-alpha.317 → 3.0.0-alpha.318
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/CHANGELOG.md +8 -0
- package/package.json +10 -10
- package/src/LocalPlatformRegistry.res +19 -0
- package/src/LocalPlatformRegistry.res.mjs +19 -1
- package/src/Platform.res +24 -2
- package/src/Platform.res.mjs +22 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +38 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +29 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +87 -0
- package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +76 -0
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res +8 -1
- package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +17 -4
- package/src/adapter/EventLog/EventLogStorage_InMemory.res +49 -2
- package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +57 -21
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res +85 -0
- package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +50 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res +3 -0
- package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +6 -2
- package/src/adapter/LocalBus.res +32 -5
- package/src/adapter/LocalBus.res.mjs +27 -7
- package/src/adapter/LocalEventLogRead.res +477 -0
- package/src/adapter/LocalEventLogRead.res.mjs +507 -0
- package/src/adapter/LocalEventTap.res +18 -80
- package/src/adapter/LocalEventTap.res.mjs +19 -108
- package/src/adapter/LocalFeed.res +433 -0
- package/src/adapter/LocalFeed.res.mjs +419 -0
- package/src/adapter/LocalLogFeed.res +217 -0
- package/src/adapter/LocalLogFeed.res.mjs +295 -0
- package/src/adapter/LocalPgEventLogRead.res +85 -0
- package/src/adapter/LocalPgEventLogRead.res.mjs +91 -0
- package/src/adapter/LocalPlatformObserve.res +456 -0
- package/src/adapter/LocalPlatformObserve.res.mjs +505 -0
- package/src/adapter/LocalTapLines.res +147 -0
- package/src/adapter/LocalTapLines.res.mjs +229 -0
- package/src/adapter/PlatformGraphQL_Server.res +36 -3
- package/src/adapter/PlatformGraphQL_Server.res.mjs +15 -1
- package/src/reexport/CommandTopic.res.mjs +5 -2
- package/tests/adapter/GraphQLWsFixtures.mjs +46 -0
- package/tests/adapter/LocalEventLogReadTest.res +539 -0
- package/tests/adapter/LocalEventLogReadTest.res.mjs +466 -0
- package/tests/adapter/LocalFeedTest.res +314 -0
- package/tests/adapter/LocalFeedTest.res.mjs +329 -0
- package/tests/adapter/LocalPlatformObserveTest.res +165 -0
- package/tests/adapter/LocalPlatformObserveTest.res.mjs +183 -0
- package/tests/adapter/LocalTapLinesTest.res +182 -0
- package/tests/adapter/LocalTapLinesTest.res.mjs +242 -0
- package/tests/adapter/PlatformSubscriptionsTest.res +123 -0
- package/tests/adapter/PlatformSubscriptionsTest.res.mjs +132 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res +80 -0
- package/tests/components/automationslice/AutomationSliceCallbackTest.res.mjs +81 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res +17 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockFixtures.res.mjs +24 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res +30 -0
- package/tests/components/automationslice/AutomationSliceSelfDeadlockTest.res.mjs +28 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res +58 -0
- package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +71 -19
- package/tests/components/outboundtranslationslice/OutboundTranslationSlicePiiTest.res.mjs +7 -7
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
// What the platform API and the platform MCP server offer a tool that watches
|
|
2
|
+
// this platform: every event of every log newest first (`Platform_EventLog`),
|
|
3
|
+
// the log level while running (`Platform_LogLevels`, `Platform_SetLogLevel`),
|
|
4
|
+
// the log feed and the event tap as subscriptions (`Platform_LogAppended`,
|
|
5
|
+
// `Platform_EventAppended`), and the MCP resources `platform/logs/recent` and
|
|
6
|
+
// `platform/events`.
|
|
7
|
+
//
|
|
8
|
+
// Every field here is admin-checked, also when the platform serves one API for
|
|
9
|
+
// both, where nothing else would check it.
|
|
10
|
+
|
|
11
|
+
module Logger = ReventlessCore.Logger
|
|
12
|
+
module GQL = ReventlessGraphqlServer.GraphQL_ServerInstance
|
|
13
|
+
|
|
14
|
+
// ─── SDL ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
let eventLogTypes = [
|
|
17
|
+
"scalar AWSJSON",
|
|
18
|
+
`input Platform_EventLogFilter {
|
|
19
|
+
plugins: [String!]
|
|
20
|
+
logs: [String!]
|
|
21
|
+
eventTypes: [String!]
|
|
22
|
+
entityId: String
|
|
23
|
+
tagKey: String
|
|
24
|
+
tagValue: String
|
|
25
|
+
msgId: String
|
|
26
|
+
correlationId: String
|
|
27
|
+
causationId: String
|
|
28
|
+
user: String
|
|
29
|
+
timeFrom: String
|
|
30
|
+
timeTo: String
|
|
31
|
+
}`,
|
|
32
|
+
`type Platform_EventLogTag {
|
|
33
|
+
key: String!
|
|
34
|
+
value: String!
|
|
35
|
+
}`,
|
|
36
|
+
`type Platform_EventLogRecord {
|
|
37
|
+
plugin: String
|
|
38
|
+
log: String!
|
|
39
|
+
position: String!
|
|
40
|
+
eventType: String!
|
|
41
|
+
entityId: String
|
|
42
|
+
tags: [Platform_EventLogTag!]!
|
|
43
|
+
payload: AWSJSON!
|
|
44
|
+
meta: AWSJSON!
|
|
45
|
+
recordedAt: String!
|
|
46
|
+
}`,
|
|
47
|
+
`type Platform_EventLogEdge {
|
|
48
|
+
node: Platform_EventLogRecord!
|
|
49
|
+
cursor: String!
|
|
50
|
+
}`,
|
|
51
|
+
`type Platform_EventLogPageInfo {
|
|
52
|
+
hasNextPage: Boolean!
|
|
53
|
+
endCursor: String
|
|
54
|
+
}`,
|
|
55
|
+
`type Platform_EventLogConnection {
|
|
56
|
+
edges: [Platform_EventLogEdge!]!
|
|
57
|
+
pageInfo: Platform_EventLogPageInfo!
|
|
58
|
+
}`,
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
let logLevelTypes = [
|
|
62
|
+
`enum Platform_LogLevel {
|
|
63
|
+
DEBUG
|
|
64
|
+
INFO
|
|
65
|
+
WARN
|
|
66
|
+
ERROR
|
|
67
|
+
}`,
|
|
68
|
+
`type Platform_LogLevelSetting {
|
|
69
|
+
plugin: String
|
|
70
|
+
comp: String
|
|
71
|
+
level: Platform_LogLevel!
|
|
72
|
+
}`,
|
|
73
|
+
`type Platform_LogLevels {
|
|
74
|
+
base: Platform_LogLevel
|
|
75
|
+
settings: [Platform_LogLevelSetting!]!
|
|
76
|
+
}`,
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
let feedTypes = [
|
|
80
|
+
`input Platform_FeedFilter {
|
|
81
|
+
plugins: [String!]
|
|
82
|
+
level: Platform_LogLevel
|
|
83
|
+
components: [String!]
|
|
84
|
+
eventTypes: [String!]
|
|
85
|
+
correlationId: String
|
|
86
|
+
}`,
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
let eventLogField = "Platform_EventLog"
|
|
90
|
+
let logLevelsField = "Platform_LogLevels"
|
|
91
|
+
let setLogLevelField = "Platform_SetLogLevel"
|
|
92
|
+
|
|
93
|
+
let queryFields = [
|
|
94
|
+
` ${eventLogField}(filter: Platform_EventLogFilter, first: Int, after: String): Platform_EventLogConnection!`,
|
|
95
|
+
` ${logLevelsField}: Platform_LogLevels!`,
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
let logAppendedField = "Platform_LogAppended"
|
|
99
|
+
let eventAppendedField = "Platform_EventAppended"
|
|
100
|
+
|
|
101
|
+
// Each value is one line of the feed as JSON: a log record (the log feed's line),
|
|
102
|
+
// an event tap line without its sentinel prefix, or `{"kind":"skipped","count":N}`.
|
|
103
|
+
let subscriptionFields = [
|
|
104
|
+
` ${logAppendedField}(filter: Platform_FeedFilter): AWSJSON!`,
|
|
105
|
+
` ${eventAppendedField}(filter: Platform_FeedFilter): AWSJSON!`,
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
let mutationFields = [
|
|
109
|
+
` ${setLogLevelField}(plugin: String, comp: String, level: Platform_LogLevel): Platform_LogLevels!`,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
// ─── Resolvers ───────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
let arg = (args: JSON.t, key: string): option<JSON.t> =>
|
|
115
|
+
args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(key))
|
|
116
|
+
|
|
117
|
+
let pageJson = (page: LocalEventLogRead.page): JSON.t =>
|
|
118
|
+
Dict.fromArray([
|
|
119
|
+
(
|
|
120
|
+
"edges",
|
|
121
|
+
page.edges
|
|
122
|
+
->Array.map(((record, cursor)) =>
|
|
123
|
+
Dict.fromArray([
|
|
124
|
+
("node", LocalEventLogRead.recordToJson(record)),
|
|
125
|
+
("cursor", JSON.Encode.string(cursor)),
|
|
126
|
+
])->JSON.Encode.object
|
|
127
|
+
)
|
|
128
|
+
->JSON.Encode.array,
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"pageInfo",
|
|
132
|
+
Dict.fromArray([
|
|
133
|
+
("hasNextPage", JSON.Encode.bool(page.hasNextPage)),
|
|
134
|
+
("endCursor", page.endCursor->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
|
|
135
|
+
])->JSON.Encode.object,
|
|
136
|
+
),
|
|
137
|
+
])->JSON.Encode.object
|
|
138
|
+
|
|
139
|
+
let eventLog: GQL.resolverFn = async (_root, args, _ctx) => {
|
|
140
|
+
let page = await LocalEventLogRead.read(
|
|
141
|
+
~filter=LocalEventLogRead.filterOfJson(arg(args, "filter")),
|
|
142
|
+
~first=?arg(args, "first")->Option.flatMap(JSON.Decode.float)->Option.map(Float.toInt),
|
|
143
|
+
~after=?arg(args, "after")->Option.flatMap(JSON.Decode.string),
|
|
144
|
+
)
|
|
145
|
+
pageJson(page)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let levelOfWire = (s: string): option<Logger.level> =>
|
|
149
|
+
switch s {
|
|
150
|
+
| "DEBUG" => Some(Debug)
|
|
151
|
+
| "INFO" => Some(Info)
|
|
152
|
+
| "WARN" => Some(Warn)
|
|
153
|
+
| "ERROR" => Some(Error)
|
|
154
|
+
| _ => None
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let levelsJson = (): JSON.t =>
|
|
158
|
+
Dict.fromArray([
|
|
159
|
+
(
|
|
160
|
+
"base",
|
|
161
|
+
Logger.effectiveMin()->Option.mapOr(JSON.Encode.null, l =>
|
|
162
|
+
l->Logger.levelLabel->JSON.Encode.string
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
"settings",
|
|
167
|
+
Logger.levelSettings()
|
|
168
|
+
->Array.map(s =>
|
|
169
|
+
[("level", s.level->Logger.levelLabel->JSON.Encode.string)]
|
|
170
|
+
->Array.concat(s.plugin->Option.mapOr([], p => [("plugin", JSON.Encode.string(p))]))
|
|
171
|
+
->Array.concat(s.comp->Option.mapOr([], c => [("comp", JSON.Encode.string(c))]))
|
|
172
|
+
->Dict.fromArray
|
|
173
|
+
->JSON.Encode.object
|
|
174
|
+
)
|
|
175
|
+
->JSON.Encode.array,
|
|
176
|
+
),
|
|
177
|
+
])->JSON.Encode.object
|
|
178
|
+
|
|
179
|
+
let logLevels: GQL.resolverFn = async (_root, _args, _ctx) => levelsJson()
|
|
180
|
+
|
|
181
|
+
let log = Logger.fromEnv()
|
|
182
|
+
|
|
183
|
+
let setLogLevel: GQL.resolverFn = async (_root, args, _ctx) => {
|
|
184
|
+
let plugin = arg(args, "plugin")->Option.flatMap(JSON.Decode.string)
|
|
185
|
+
let comp = arg(args, "comp")->Option.flatMap(JSON.Decode.string)
|
|
186
|
+
let level = arg(args, "level")->Option.flatMap(JSON.Decode.string)->Option.flatMap(levelOfWire)
|
|
187
|
+
Logger.setLevel(~plugin?, ~comp?, level)
|
|
188
|
+
let scope = switch (plugin, comp) {
|
|
189
|
+
| (Some(p), Some(c)) => `${c} of ${p}`
|
|
190
|
+
| (None, Some(c)) => c
|
|
191
|
+
| (Some(p), None) => p
|
|
192
|
+
| (None, None) => "every line"
|
|
193
|
+
}
|
|
194
|
+
log.info(
|
|
195
|
+
~comp="Platform",
|
|
196
|
+
switch level {
|
|
197
|
+
| Some(l) => `log level for ${scope} set to ${l->Logger.levelLabel}`
|
|
198
|
+
| None => `log level for ${scope} cleared`
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
levelsJson()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── The feeds as subscriptions ──────────────────────────────────────────────
|
|
205
|
+
// A subscriber is one more listener on the feed, with the same filter, bound and
|
|
206
|
+
// `skipped` line; its sink hands lines to the subscription as the client takes
|
|
207
|
+
// them, and asks the feed to wait while the client is behind.
|
|
208
|
+
|
|
209
|
+
let feedFilterOf = (json: option<JSON.t>): LocalFeed.filter => {
|
|
210
|
+
let d = json->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
|
|
211
|
+
let a = k =>
|
|
212
|
+
d
|
|
213
|
+
->Dict.get(k)
|
|
214
|
+
->Option.flatMap(JSON.Decode.array)
|
|
215
|
+
->Option.map(items => items->Array.filterMap(JSON.Decode.string))
|
|
216
|
+
{
|
|
217
|
+
plugins: ?a("plugins"),
|
|
218
|
+
level: ?(d->Dict.get("level")->Option.flatMap(JSON.Decode.string)->Option.flatMap(levelOfWire)),
|
|
219
|
+
components: ?a("components"),
|
|
220
|
+
eventTypes: ?a("eventTypes"),
|
|
221
|
+
correlationId: ?(d->Dict.get("correlationId")->Option.flatMap(JSON.Decode.string)),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
type asyncIterable
|
|
226
|
+
type iteratorResult = {done: bool, value?: JSON.t}
|
|
227
|
+
|
|
228
|
+
let makeAsyncIterable: (
|
|
229
|
+
unit => promise<iteratorResult>,
|
|
230
|
+
unit => promise<iteratorResult>,
|
|
231
|
+
) => asyncIterable = %raw(`function (next, ret) {
|
|
232
|
+
return { next, return: ret, [Symbol.asyncIterator]() { return this } }
|
|
233
|
+
}`)
|
|
234
|
+
|
|
235
|
+
// How many lines a subscription holds for its client before the feed is asked to
|
|
236
|
+
// wait; beyond that the feed's own bounded queue takes over.
|
|
237
|
+
let subscriptionWindow = 256
|
|
238
|
+
|
|
239
|
+
let tapSentinel = "@@RVLESS_EVT@@ "
|
|
240
|
+
|
|
241
|
+
/** A line as the subscription delivers it: JSON, without the tap's prefix. */
|
|
242
|
+
let lineToJson = (line: string): JSON.t => {
|
|
243
|
+
let body =
|
|
244
|
+
line->String.startsWith(tapSentinel)
|
|
245
|
+
? line->String.slice(~start=tapSentinel->String.length, ~end=line->String.length)
|
|
246
|
+
: line
|
|
247
|
+
try body->JSON.parseOrThrow catch {
|
|
248
|
+
| _ => JSON.Encode.string(line)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let feedSubscription = (feed: LocalFeed.t, ~filter: LocalFeed.filter): asyncIterable => {
|
|
253
|
+
let pending: array<JSON.t> = []
|
|
254
|
+
let waiting: ref<option<iteratorResult => unit>> = ref(None)
|
|
255
|
+
let drainers: ref<array<unit => unit>> = ref([])
|
|
256
|
+
let closed = ref(false)
|
|
257
|
+
let sink: LocalFeed.sink = {
|
|
258
|
+
write: line => {
|
|
259
|
+
let value = lineToJson(line)
|
|
260
|
+
switch waiting.contents {
|
|
261
|
+
| Some(resolve) =>
|
|
262
|
+
waiting := None
|
|
263
|
+
resolve({done: false, value})
|
|
264
|
+
| None => pending->Array.push(value)
|
|
265
|
+
}
|
|
266
|
+
pending->Array.length < subscriptionWindow
|
|
267
|
+
},
|
|
268
|
+
onceDrain: f => drainers := drainers.contents->Array.concat([f]),
|
|
269
|
+
buffered: () => pending->Array.reduce(0, (sum, v) => sum + v->JSON.stringify->String.length),
|
|
270
|
+
close: () => closed := true,
|
|
271
|
+
}
|
|
272
|
+
let unsubscribe = feed->LocalFeed.subscribe(~filter, sink)
|
|
273
|
+
let next = () =>
|
|
274
|
+
switch pending->Array.shift {
|
|
275
|
+
| Some(value) =>
|
|
276
|
+
if pending->Array.length < subscriptionWindow / 2 {
|
|
277
|
+
let toCall = drainers.contents
|
|
278
|
+
drainers := []
|
|
279
|
+
toCall->Array.forEach(f => f())
|
|
280
|
+
}
|
|
281
|
+
Promise.resolve({done: false, value})
|
|
282
|
+
| None if closed.contents => Promise.resolve({done: true})
|
|
283
|
+
| None => Promise.make((resolve, _) => waiting := Some(resolve))
|
|
284
|
+
}
|
|
285
|
+
let return = () => {
|
|
286
|
+
closed := true
|
|
287
|
+
unsubscribe()
|
|
288
|
+
waiting.contents->Option.forEach(resolve => resolve({done: true}))
|
|
289
|
+
waiting := None
|
|
290
|
+
Promise.resolve({done: true})
|
|
291
|
+
}
|
|
292
|
+
makeAsyncIterable(next, return)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type subscriptionResolver = {
|
|
296
|
+
subscribe: GQL.resolverFn,
|
|
297
|
+
resolve: JSON.t => JSON.t,
|
|
298
|
+
}
|
|
299
|
+
external toResolverFn: subscriptionResolver => GQL.resolverFn = "%identity"
|
|
300
|
+
external fromAsyncIterable: asyncIterable => JSON.t = "%identity"
|
|
301
|
+
|
|
302
|
+
let feedField = (feed: LocalFeed.t): GQL.resolverFn =>
|
|
303
|
+
toResolverFn({
|
|
304
|
+
subscribe: Auth_GraphqlContext.requireGroup(~group=Reventless.Role.adminGroup(), async (
|
|
305
|
+
_root,
|
|
306
|
+
args,
|
|
307
|
+
_ctx,
|
|
308
|
+
) => feedSubscription(feed, ~filter=feedFilterOf(arg(args, "filter")))->fromAsyncIterable),
|
|
309
|
+
resolve: payload => payload,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
let adminOnly = (resolvers: array<(string, GQL.resolverFn)>): dict<GQL.resolverFn> =>
|
|
313
|
+
resolvers
|
|
314
|
+
->Array.map(((name, resolver)) => (
|
|
315
|
+
name,
|
|
316
|
+
Auth_GraphqlContext.requireGroup(~group=Reventless.Role.adminGroup(), resolver),
|
|
317
|
+
))
|
|
318
|
+
->Dict.fromArray
|
|
319
|
+
|
|
320
|
+
/** Registers the fields on the platform API. */
|
|
321
|
+
let registerGraphQL = (server: GQL.t) => {
|
|
322
|
+
server.registerTypes(
|
|
323
|
+
~sdlTypes=eventLogTypes->Array.concat(logLevelTypes)->Array.concat(feedTypes),
|
|
324
|
+
)
|
|
325
|
+
server.registerQueries(
|
|
326
|
+
~sdlFields=queryFields,
|
|
327
|
+
~resolvers=adminOnly([(eventLogField, eventLog), (logLevelsField, logLevels)]),
|
|
328
|
+
)
|
|
329
|
+
server.registerMutations(
|
|
330
|
+
~sdlFields=mutationFields,
|
|
331
|
+
~resolvers=adminOnly([(setLogLevelField, setLogLevel)]),
|
|
332
|
+
)
|
|
333
|
+
server.registerSubscriptions(
|
|
334
|
+
~sdlFields=subscriptionFields,
|
|
335
|
+
~resolvers=Dict.fromArray([
|
|
336
|
+
(logAppendedField, feedField(LocalLogFeed.feed)),
|
|
337
|
+
(eventAppendedField, feedField(LocalEventTap.feed)),
|
|
338
|
+
]),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ─── MCP resources ───────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
let recentLogsUri = "platform/logs/recent"
|
|
345
|
+
let eventsUri = "platform/events"
|
|
346
|
+
|
|
347
|
+
@val external decodeURIComponent: string => string = "decodeURIComponent"
|
|
348
|
+
|
|
349
|
+
/** The query string of a resource URI, decoded. */
|
|
350
|
+
let queryOf = (uri: string): dict<string> => {
|
|
351
|
+
let params = Dict.make()
|
|
352
|
+
switch uri->String.split("?")->Array.get(1) {
|
|
353
|
+
| None => ()
|
|
354
|
+
| Some(qs) =>
|
|
355
|
+
qs
|
|
356
|
+
->String.split("&")
|
|
357
|
+
->Array.forEach(pair => {
|
|
358
|
+
let decode = s =>
|
|
359
|
+
try decodeURIComponent(s->String.replaceAll("+", " ")) catch {
|
|
360
|
+
| _ => s
|
|
361
|
+
}
|
|
362
|
+
switch pair->String.indexOf("=") {
|
|
363
|
+
| -1 => ()
|
|
364
|
+
| i =>
|
|
365
|
+
params->Dict.set(
|
|
366
|
+
decode(pair->String.slice(~start=0, ~end=i)),
|
|
367
|
+
decode(pair->String.slice(~start=i + 1, ~end=pair->String.length)),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
params
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let listOf = (q: dict<string>, key: string): option<array<string>> =>
|
|
376
|
+
q
|
|
377
|
+
->Dict.get(key)
|
|
378
|
+
->Option.map(v => v->String.split(",")->Array.map(String.trim)->Array.filter(s => s != ""))
|
|
379
|
+
|
|
380
|
+
let intOf = (q: dict<string>, key: string): option<int> =>
|
|
381
|
+
q->Dict.get(key)->Option.flatMap(v => Int.fromString(v))
|
|
382
|
+
|
|
383
|
+
let resourceResult = (~uri: string, json: JSON.t): McpSdk.readResourceResult => {
|
|
384
|
+
McpSdk.contents: [{McpSdk.uri, text: json->JSON.stringify}],
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** `platform/logs/recent?plugins=a,b&level=warn&components=c&correlationId=x&limit=n`:
|
|
388
|
+
the recent records the filter selects, oldest first, at most `limit`
|
|
389
|
+
(default 200) of the most recent. */
|
|
390
|
+
let recentLogs: MCP_ServerInstance.resourceHandler = async uri => {
|
|
391
|
+
let q = queryOf(uri)
|
|
392
|
+
let filter: LocalFeed.filter = {
|
|
393
|
+
plugins: ?listOf(q, "plugins"),
|
|
394
|
+
level: ?(q->Dict.get("level")->Option.flatMap(LocalFeed.levelOfString)),
|
|
395
|
+
components: ?listOf(q, "components"),
|
|
396
|
+
correlationId: ?(q->Dict.get("correlationId")),
|
|
397
|
+
}
|
|
398
|
+
let records = LocalLogFeed.recentRecords(~filter, ~limit=intOf(q, "limit")->Option.getOr(200))
|
|
399
|
+
resourceResult(
|
|
400
|
+
~uri,
|
|
401
|
+
Dict.fromArray([
|
|
402
|
+
("records", records->Array.map(Logger.recordToJson)->JSON.Encode.array),
|
|
403
|
+
])->JSON.Encode.object,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** `platform/events?…`: one page of the read across every log, newest first.
|
|
408
|
+
Takes the filter of `Platform_EventLog` as query parameters (lists comma
|
|
409
|
+
separated) and `first` and `after`. */
|
|
410
|
+
let events: MCP_ServerInstance.resourceHandler = async uri => {
|
|
411
|
+
let q = queryOf(uri)
|
|
412
|
+
let filter: LocalEventLogRead.filter = {
|
|
413
|
+
plugins: ?listOf(q, "plugins"),
|
|
414
|
+
logs: ?listOf(q, "logs"),
|
|
415
|
+
eventTypes: ?listOf(q, "eventTypes"),
|
|
416
|
+
entityId: ?(q->Dict.get("entityId")),
|
|
417
|
+
tagKey: ?(q->Dict.get("tagKey")),
|
|
418
|
+
tagValue: ?(q->Dict.get("tagValue")),
|
|
419
|
+
msgId: ?(q->Dict.get("msgId")),
|
|
420
|
+
correlationId: ?(q->Dict.get("correlationId")),
|
|
421
|
+
causationId: ?(q->Dict.get("causationId")),
|
|
422
|
+
user: ?(q->Dict.get("user")),
|
|
423
|
+
timeFrom: ?(q->Dict.get("timeFrom")),
|
|
424
|
+
timeTo: ?(q->Dict.get("timeTo")),
|
|
425
|
+
}
|
|
426
|
+
let page = await LocalEventLogRead.read(
|
|
427
|
+
~filter,
|
|
428
|
+
~first=?intOf(q, "first"),
|
|
429
|
+
~after=?q->Dict.get("after"),
|
|
430
|
+
)
|
|
431
|
+
resourceResult(~uri, pageJson(page))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Registers the resources on the platform MCP server. */
|
|
435
|
+
let registerMcp = (server: MCP_ServerInstance.t) => {
|
|
436
|
+
server.registerResource(
|
|
437
|
+
~name=recentLogsUri,
|
|
438
|
+
~definition={
|
|
439
|
+
uriTemplate: recentLogsUri,
|
|
440
|
+
name: recentLogsUri,
|
|
441
|
+
description: "The platform's recent log records, oldest first. Query parameters: plugins, level, components, correlationId (narrow), limit (default 200).",
|
|
442
|
+
mimeType: "application/json",
|
|
443
|
+
},
|
|
444
|
+
~handler=recentLogs,
|
|
445
|
+
)
|
|
446
|
+
server.registerResource(
|
|
447
|
+
~name=eventsUri,
|
|
448
|
+
~definition={
|
|
449
|
+
uriTemplate: eventsUri,
|
|
450
|
+
name: eventsUri,
|
|
451
|
+
description: "Every event of every log, newest first, one page at a time. Query parameters: plugins, logs, eventTypes, entityId, tagKey, tagValue, msgId, correlationId, causationId, user, timeFrom, timeTo, first, after (the page's endCursor).",
|
|
452
|
+
mimeType: "application/json",
|
|
453
|
+
},
|
|
454
|
+
~handler=events,
|
|
455
|
+
)
|
|
456
|
+
}
|