@ancplua/qyl-api-schema 11.4.0 → 11.6.0-rc.1
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/README.md +48 -6
- package/api/routes.tsp +293 -5
- package/api/runner.tsp +2 -0
- package/common/auth.tsp +20 -0
- package/common/errors.tsp +28 -0
- package/common/resource.tsp +106 -0
- package/generated/json-schema/qyl-api-schema.json +1989 -149
- package/generated/openapi/qyl.openapi.json +7823 -3927
- package/generated/ts-runtime/api.d.ts +315 -3
- package/generated/ts-runtime/api.js +25 -1
- package/generated/ts-runtime/schemas.d.ts +130 -2
- package/generated/ts-runtime/schemas.js +128 -0
- package/models/annotations.tsp +83 -0
- package/models/mcp-tools.tsp +436 -7
- package/models/metrics.tsp +198 -0
- package/models/saved-queries.tsp +212 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -4,12 +4,42 @@ The TypeSpec source of truth for Qyl's client-visible product API. This reposito
|
|
|
4
4
|
emits the public schema and contract artifacts; it does not implement the server,
|
|
5
5
|
storage engine, or OTLP receiver.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
`GET /api/v1/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
The contract covers every client-visible surface of a qyl collector and runner:
|
|
8
|
+
|
|
9
|
+
- **Traces** — `GET /api/v1/traces`, one trace, and its spans (`api/routes.tsp`,
|
|
10
|
+
`otel/span.tsp`).
|
|
11
|
+
- **Logs** — `GET /api/v1/logs` and the live SSE stream `GET /api/v1/stream/logs`
|
|
12
|
+
with its heartbeat (`api/streaming.tsp`, `otel/logs.tsp`).
|
|
13
|
+
- **Metrics**, read-only — `GET /api/v1/metrics` lists the metric descriptors
|
|
14
|
+
recorded for a project, `GET /api/v1/metrics/{metric_name}/series` lists the
|
|
15
|
+
distinct attribute streams under one metric name, and
|
|
16
|
+
`GET /api/v1/metrics/{metric_name}/query` aggregates one metric over a time range
|
|
17
|
+
into buckets (`models/metrics.tsp`). Metric points are written over OTLP, which
|
|
18
|
+
this contract does not describe. Profiles are not supported.
|
|
19
|
+
- **Sessions** — `GET /api/v1/sessions`, one session, its traces and `/stats`
|
|
20
|
+
(`models/session.tsp`).
|
|
21
|
+
- **Health** — `GET /health` and `GET /alive`, whose `HealthReport` carries the
|
|
22
|
+
`contract_revision` handshake (`models/health.tsp`).
|
|
23
|
+
- **MCP tools** — the input and structured-output models of the qyl MCP tools
|
|
24
|
+
(`models/mcp-tools.tsp`).
|
|
25
|
+
- **Workbench** — the local MCP workbench: sessions, workspaces, servers, protocol
|
|
26
|
+
streams, executions, test cases, suites, evaluation runs and exports
|
|
27
|
+
(`api/workbench.tsp`, `models/workbench.tsp`).
|
|
28
|
+
- **Runner** — the local runner's loopback-only `/runner/resources` snapshot, SSE
|
|
29
|
+
streams, logs and restart/stop actions (`api/runner.tsp`, `models/runner.tsp`).
|
|
30
|
+
- **Shared** — `ProblemDetails` errors, cursor pagination and the common scalars
|
|
31
|
+
(`common/`), plus the OTel resource, span and log projections (`otel/`).
|
|
32
|
+
|
|
33
|
+
Telemetry itself is never changed or deleted through this contract; retention
|
|
34
|
+
removes it. The only writable resources are the ones a user creates:
|
|
35
|
+
annotations (`/api/v1/annotations`, a note of at most 2000 characters on a trace,
|
|
36
|
+
span or session) and saved queries (`/api/v1/saved-queries`, a named structured
|
|
37
|
+
filter for traces, logs or metrics, run with `POST …/{saved_query_id}:run`). Both
|
|
38
|
+
extend the shared write base in `common/resource.tsp`: server-assigned `id`,
|
|
39
|
+
`created_at`, `updated_at` and `etag`; `Idempotency-Key` on create, which answers
|
|
40
|
+
`201` with `Location`; `If-Match` on `PATCH` and `DELETE`, refused with `428` when
|
|
41
|
+
missing and `412` when stale. Collector keys carry the roles `ingest`, `read`,
|
|
42
|
+
`write` and `delete` (`CollectorKeyRole`); writes need `write`, deletes `delete`.
|
|
13
43
|
|
|
14
44
|
## Contract pipeline
|
|
15
45
|
|
|
@@ -56,6 +86,18 @@ rather than from prose here; this line moves faster than a README is revised.
|
|
|
56
86
|
`main.tsp` is the local compile entry point and includes emitter routing. `index.tsp`
|
|
57
87
|
is the published TypeSpec entry point and contains only the client-facing contract.
|
|
58
88
|
|
|
89
|
+
### Package exports
|
|
90
|
+
|
|
91
|
+
| Subpath | Content |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `@ancplua/qyl-api-schema` | `index.tsp`, the whole TypeSpec contract |
|
|
94
|
+
| `@ancplua/qyl-api-schema/otel` | `otel/otel-conventions.tsp`: only the OTel-shaped resource, span and log models, for TypeSpec consumers that do not want the qyl API |
|
|
95
|
+
| `@ancplua/qyl-api-schema/types` | Generated TypeScript contract types and the `CONTRACT_REVISION` constant |
|
|
96
|
+
| `@ancplua/qyl-api-schema/openapi` | `qyl.openapi.json` |
|
|
97
|
+
| `@ancplua/qyl-api-schema/json-schema` | The bundled JSON Schema |
|
|
98
|
+
| `@ancplua/qyl-api-schema/zod` | Runtime validators, below |
|
|
99
|
+
| `@ancplua/qyl-api-schema/runtime` | Presentation helpers over the generated `AttributeValue` matcher (`decodeAttributeValue`, `formatAttributeValue`, `attributeString`, `attributeNumber`, `attributeIdentity`, `matchAttributeValue`) and the `UnixNanos` string helpers (`parseUnixNanos`, `compareUnixNanos`, `unixNanosDelta`, `unixNanosToEpochMs`, `unixNanosToIso`, `epochMsToUnixNanos`). Authored in `src/runtime/`, compiled to `generated/runtime/` |
|
|
100
|
+
|
|
59
101
|
## Runtime validation
|
|
60
102
|
|
|
61
103
|
`@ancplua/qyl-api-schema/zod` builds Zod validators from the bundled JSON Schema at
|
package/api/routes.tsp
CHANGED
|
@@ -9,6 +9,10 @@ import "../otel/logs.tsp";
|
|
|
9
9
|
import "../models/session.tsp";
|
|
10
10
|
import "../models/health.tsp";
|
|
11
11
|
import "../models/metrics.tsp";
|
|
12
|
+
import "../common/resource.tsp";
|
|
13
|
+
import "../common/auth.tsp";
|
|
14
|
+
import "../models/annotations.tsp";
|
|
15
|
+
import "../models/saved-queries.tsp";
|
|
12
16
|
|
|
13
17
|
using TypeSpec.Http;
|
|
14
18
|
using TypeSpec.OpenAPI;
|
|
@@ -22,10 +26,13 @@ using Qyl.Api.Contracts.OTel.Metrics;
|
|
|
22
26
|
using Qyl.Api.Contracts.OTel.Enums;
|
|
23
27
|
using Qyl.Api.Contracts.Domains.Observe.Session;
|
|
24
28
|
using Qyl.Api.Contracts.Health;
|
|
29
|
+
using Qyl.Api.Contracts.Annotations;
|
|
30
|
+
using Qyl.Api.Contracts.SavedQueries;
|
|
25
31
|
|
|
26
32
|
@service(#{ title: "qyl API" })
|
|
27
33
|
namespace Qyl.Api.Contracts;
|
|
28
34
|
|
|
35
|
+
// The query routes require a key with the `read` role (CollectorKeyRole.read).
|
|
29
36
|
alias QylApiKeyHeaderAuth = ApiKeyAuth<ApiKeyLocation.header, "x-otlp-api-key">;
|
|
30
37
|
|
|
31
38
|
@doc("Optional project scope applied to collector query operations.")
|
|
@@ -46,12 +53,28 @@ interface TracesApi {
|
|
|
46
53
|
list(
|
|
47
54
|
...ProjectScopeHeader,
|
|
48
55
|
|
|
56
|
+
@doc("Keep traces that started at or after this time.")
|
|
57
|
+
@query("start_time")
|
|
58
|
+
startTime?: utcDateTime,
|
|
59
|
+
|
|
60
|
+
@doc("Keep traces that started before this time.")
|
|
61
|
+
@query("end_time")
|
|
62
|
+
endTime?: utcDateTime,
|
|
63
|
+
|
|
64
|
+
@doc("Keep traces with at least one span from this service.")
|
|
65
|
+
@query("service_name")
|
|
66
|
+
serviceName?: string,
|
|
67
|
+
|
|
68
|
+
@doc("Keep only traces with at least one error span.")
|
|
69
|
+
@query("errors_only")
|
|
70
|
+
errorsOnly?: boolean,
|
|
71
|
+
|
|
49
72
|
@query
|
|
50
73
|
@minValue(1)
|
|
51
74
|
@maxValue(1000)
|
|
52
75
|
limit?: int32 = 100,
|
|
53
76
|
|
|
54
|
-
@query cursor?:
|
|
77
|
+
@query cursor?: Cursor,
|
|
55
78
|
):
|
|
56
79
|
| CursorPage<Trace>
|
|
57
80
|
| ValidationError
|
|
@@ -68,8 +91,20 @@ interface TracesApi {
|
|
|
68
91
|
|
|
69
92
|
@get
|
|
70
93
|
@route("/{trace_id}/spans")
|
|
71
|
-
getSpans(
|
|
94
|
+
getSpans(
|
|
95
|
+
...ProjectScopeHeader,
|
|
96
|
+
@path("trace_id") traceId: TraceId,
|
|
97
|
+
|
|
98
|
+
@doc("Page size. Omitted, every span of the trace is returned in one page.")
|
|
99
|
+
@query
|
|
100
|
+
@minValue(1)
|
|
101
|
+
@maxValue(10000)
|
|
102
|
+
limit?: int32,
|
|
103
|
+
|
|
104
|
+
@query cursor?: Cursor,
|
|
105
|
+
):
|
|
72
106
|
| CursorPage<Span>
|
|
107
|
+
| ValidationError
|
|
73
108
|
| NotFoundError
|
|
74
109
|
| UnauthorizedError
|
|
75
110
|
| InternalServerError;
|
|
@@ -99,6 +134,8 @@ interface LogsApi {
|
|
|
99
134
|
@minValue(1)
|
|
100
135
|
@maxValue(10000)
|
|
101
136
|
limit?: int32 = 100,
|
|
137
|
+
|
|
138
|
+
@query cursor?: Cursor,
|
|
102
139
|
):
|
|
103
140
|
| CursorPage<LogRecord>
|
|
104
141
|
| ValidationError
|
|
@@ -132,6 +169,8 @@ interface MetricsApi {
|
|
|
132
169
|
@minValue(1)
|
|
133
170
|
@maxValue(1000)
|
|
134
171
|
limit?: int32 = 200,
|
|
172
|
+
|
|
173
|
+
@query cursor?: Cursor,
|
|
135
174
|
):
|
|
136
175
|
| CursorPage<MetricDescriptor>
|
|
137
176
|
| ValidationError
|
|
@@ -160,6 +199,8 @@ interface MetricsApi {
|
|
|
160
199
|
@minValue(1)
|
|
161
200
|
@maxValue(1000)
|
|
162
201
|
limit?: int32 = 200,
|
|
202
|
+
|
|
203
|
+
@query cursor?: Cursor,
|
|
163
204
|
):
|
|
164
205
|
| CursorPage<MetricSeries>
|
|
165
206
|
| ValidationError
|
|
@@ -176,7 +217,6 @@ interface MetricsApi {
|
|
|
176
217
|
query(
|
|
177
218
|
...ProjectScopeHeader,
|
|
178
219
|
@path("metric_name") metricName: string,
|
|
179
|
-
|
|
180
220
|
@query("start_time") startTime: utcDateTime,
|
|
181
221
|
@query("end_time") endTime: utcDateTime,
|
|
182
222
|
|
|
@@ -233,7 +273,7 @@ interface SessionsApi {
|
|
|
233
273
|
@maxValue(1000)
|
|
234
274
|
limit?: int32 = 100,
|
|
235
275
|
|
|
236
|
-
@query cursor?:
|
|
276
|
+
@query cursor?: Cursor,
|
|
237
277
|
):
|
|
238
278
|
| CursorPage<SessionEntity>
|
|
239
279
|
| ValidationError
|
|
@@ -250,8 +290,20 @@ interface SessionsApi {
|
|
|
250
290
|
|
|
251
291
|
@get
|
|
252
292
|
@route("/{session_id}/traces")
|
|
253
|
-
getTraces(
|
|
293
|
+
getTraces(
|
|
294
|
+
...ProjectScopeHeader,
|
|
295
|
+
@path("session_id") sessionId: SessionId,
|
|
296
|
+
|
|
297
|
+
@doc("Page size; the response is bounded even when omitted.")
|
|
298
|
+
@query
|
|
299
|
+
@minValue(1)
|
|
300
|
+
@maxValue(1000)
|
|
301
|
+
limit?: int32 = 100,
|
|
302
|
+
|
|
303
|
+
@query cursor?: Cursor,
|
|
304
|
+
):
|
|
254
305
|
| CursorPage<Trace>
|
|
306
|
+
| ValidationError
|
|
255
307
|
| NotFoundError
|
|
256
308
|
| UnauthorizedError
|
|
257
309
|
| InternalServerError;
|
|
@@ -265,6 +317,242 @@ interface SessionsApi {
|
|
|
265
317
|
): SessionStats | ValidationError | UnauthorizedError | InternalServerError;
|
|
266
318
|
}
|
|
267
319
|
|
|
320
|
+
// -----------------------------------------------------------------------------
|
|
321
|
+
// Annotations
|
|
322
|
+
//
|
|
323
|
+
// User notes on a trace, span or session (models/annotations.tsp). Reads take
|
|
324
|
+
// a `read` key, create and update a `write` key, delete a `delete` key.
|
|
325
|
+
// -----------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
@route("/api/v1/annotations")
|
|
328
|
+
@tag("Annotations")
|
|
329
|
+
interface AnnotationsApi {
|
|
330
|
+
@doc("Attach a note of at most 2000 characters to a trace, span or session.")
|
|
331
|
+
@useAuth(QylWriteKeyAuth)
|
|
332
|
+
@post
|
|
333
|
+
create(
|
|
334
|
+
...ProjectScopeHeader,
|
|
335
|
+
...IdempotencyKeyHeader,
|
|
336
|
+
@body request: AnnotationCreateRequest,
|
|
337
|
+
):
|
|
338
|
+
| {
|
|
339
|
+
@statusCode statusCode: 201;
|
|
340
|
+
|
|
341
|
+
@doc("URL of the created annotation.")
|
|
342
|
+
@header("Location")
|
|
343
|
+
location: string;
|
|
344
|
+
|
|
345
|
+
@header("ETag") etag: ETag;
|
|
346
|
+
@body body: Annotation;
|
|
347
|
+
}
|
|
348
|
+
| ValidationError
|
|
349
|
+
| UnauthorizedError
|
|
350
|
+
| ForbiddenError
|
|
351
|
+
| NotFoundError
|
|
352
|
+
| ConflictError
|
|
353
|
+
| InternalServerError;
|
|
354
|
+
|
|
355
|
+
@doc("List annotations, newest first, optionally only those attached to one trace, span or session.")
|
|
356
|
+
@useAuth(QylApiKeyHeaderAuth)
|
|
357
|
+
@get
|
|
358
|
+
list(
|
|
359
|
+
...ProjectScopeHeader,
|
|
360
|
+
|
|
361
|
+
@doc("Annotations on this trace, including those on its spans.")
|
|
362
|
+
@query("trace_id")
|
|
363
|
+
traceId?: TraceId,
|
|
364
|
+
|
|
365
|
+
@doc("Annotations on this span; requires `trace_id`.")
|
|
366
|
+
@query("span_id")
|
|
367
|
+
spanId?: SpanId,
|
|
368
|
+
|
|
369
|
+
@query("session_id") sessionId?: SessionId,
|
|
370
|
+
|
|
371
|
+
@query
|
|
372
|
+
@minValue(1)
|
|
373
|
+
@maxValue(1000)
|
|
374
|
+
limit?: int32 = 100,
|
|
375
|
+
|
|
376
|
+
@query cursor?: Cursor,
|
|
377
|
+
):
|
|
378
|
+
| CursorPage<Annotation>
|
|
379
|
+
| ValidationError
|
|
380
|
+
| UnauthorizedError
|
|
381
|
+
| InternalServerError;
|
|
382
|
+
|
|
383
|
+
@useAuth(QylApiKeyHeaderAuth)
|
|
384
|
+
@get
|
|
385
|
+
@route("/{annotation_id}")
|
|
386
|
+
get(...ProjectScopeHeader, @path("annotation_id") annotationId: ResourceId):
|
|
387
|
+
| {
|
|
388
|
+
@header("ETag") etag: ETag;
|
|
389
|
+
@body body: Annotation;
|
|
390
|
+
}
|
|
391
|
+
| NotFoundError
|
|
392
|
+
| UnauthorizedError
|
|
393
|
+
| InternalServerError;
|
|
394
|
+
|
|
395
|
+
@doc("Replace the text of an annotation. The target is fixed at creation.")
|
|
396
|
+
@useAuth(QylWriteKeyAuth)
|
|
397
|
+
@patch(#{ implicitOptionality: false })
|
|
398
|
+
@route("/{annotation_id}")
|
|
399
|
+
update(
|
|
400
|
+
...ProjectScopeHeader,
|
|
401
|
+
...IfMatchHeader,
|
|
402
|
+
@path("annotation_id") annotationId: ResourceId,
|
|
403
|
+
@body request: AnnotationUpdateRequest,
|
|
404
|
+
):
|
|
405
|
+
| {
|
|
406
|
+
@header("ETag") etag: ETag;
|
|
407
|
+
@body body: Annotation;
|
|
408
|
+
}
|
|
409
|
+
| ValidationError
|
|
410
|
+
| UnauthorizedError
|
|
411
|
+
| ForbiddenError
|
|
412
|
+
| NotFoundError
|
|
413
|
+
| PreconditionFailedError
|
|
414
|
+
| PreconditionRequiredError
|
|
415
|
+
| InternalServerError;
|
|
416
|
+
|
|
417
|
+
@doc("Delete one annotation. Telemetry is never deleted.")
|
|
418
|
+
@useAuth(QylDeleteKeyAuth)
|
|
419
|
+
@delete
|
|
420
|
+
@route("/{annotation_id}")
|
|
421
|
+
delete(
|
|
422
|
+
...ProjectScopeHeader,
|
|
423
|
+
...IfMatchHeader,
|
|
424
|
+
@path("annotation_id") annotationId: ResourceId,
|
|
425
|
+
):
|
|
426
|
+
| NoContentResponse
|
|
427
|
+
| UnauthorizedError
|
|
428
|
+
| ForbiddenError
|
|
429
|
+
| NotFoundError
|
|
430
|
+
| PreconditionFailedError
|
|
431
|
+
| PreconditionRequiredError
|
|
432
|
+
| InternalServerError;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// -----------------------------------------------------------------------------
|
|
436
|
+
// Saved queries
|
|
437
|
+
//
|
|
438
|
+
// Named, structured filters for traces, logs or metrics
|
|
439
|
+
// (models/saved-queries.tsp). Names are unique per project.
|
|
440
|
+
// -----------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
@route("/api/v1/saved-queries")
|
|
443
|
+
@tag("Saved queries")
|
|
444
|
+
interface SavedQueriesApi {
|
|
445
|
+
@doc("Save a named filter. A name already used in the project is refused with 409.")
|
|
446
|
+
@useAuth(QylWriteKeyAuth)
|
|
447
|
+
@post
|
|
448
|
+
create(
|
|
449
|
+
...ProjectScopeHeader,
|
|
450
|
+
...IdempotencyKeyHeader,
|
|
451
|
+
@body request: SavedQueryCreateRequest,
|
|
452
|
+
):
|
|
453
|
+
| {
|
|
454
|
+
@statusCode statusCode: 201;
|
|
455
|
+
|
|
456
|
+
@doc("URL of the created saved query.")
|
|
457
|
+
@header("Location")
|
|
458
|
+
location: string;
|
|
459
|
+
|
|
460
|
+
@header("ETag") etag: ETag;
|
|
461
|
+
@body body: SavedQuery;
|
|
462
|
+
}
|
|
463
|
+
| ValidationError
|
|
464
|
+
| UnauthorizedError
|
|
465
|
+
| ForbiddenError
|
|
466
|
+
| ConflictError
|
|
467
|
+
| InternalServerError;
|
|
468
|
+
|
|
469
|
+
@doc("List the project's saved queries by name.")
|
|
470
|
+
@useAuth(QylApiKeyHeaderAuth)
|
|
471
|
+
@get
|
|
472
|
+
list(
|
|
473
|
+
...ProjectScopeHeader,
|
|
474
|
+
@query signal?: SavedQuerySignal,
|
|
475
|
+
|
|
476
|
+
@query
|
|
477
|
+
@minValue(1)
|
|
478
|
+
@maxValue(1000)
|
|
479
|
+
limit?: int32 = 100,
|
|
480
|
+
|
|
481
|
+
@query cursor?: Cursor,
|
|
482
|
+
):
|
|
483
|
+
| CursorPage<SavedQuery>
|
|
484
|
+
| ValidationError
|
|
485
|
+
| UnauthorizedError
|
|
486
|
+
| InternalServerError;
|
|
487
|
+
|
|
488
|
+
@useAuth(QylApiKeyHeaderAuth)
|
|
489
|
+
@get
|
|
490
|
+
@route("/{saved_query_id}")
|
|
491
|
+
get(...ProjectScopeHeader, @path("saved_query_id") savedQueryId: ResourceId):
|
|
492
|
+
| {
|
|
493
|
+
@header("ETag") etag: ETag;
|
|
494
|
+
@body body: SavedQuery;
|
|
495
|
+
}
|
|
496
|
+
| NotFoundError
|
|
497
|
+
| UnauthorizedError
|
|
498
|
+
| InternalServerError;
|
|
499
|
+
|
|
500
|
+
@doc("Change a saved query. Renaming onto a name already used in the project is refused with 409.")
|
|
501
|
+
@useAuth(QylWriteKeyAuth)
|
|
502
|
+
@patch(#{ implicitOptionality: false })
|
|
503
|
+
@route("/{saved_query_id}")
|
|
504
|
+
update(
|
|
505
|
+
...ProjectScopeHeader,
|
|
506
|
+
...IfMatchHeader,
|
|
507
|
+
@path("saved_query_id") savedQueryId: ResourceId,
|
|
508
|
+
@body request: SavedQueryUpdateRequest,
|
|
509
|
+
):
|
|
510
|
+
| {
|
|
511
|
+
@header("ETag") etag: ETag;
|
|
512
|
+
@body body: SavedQuery;
|
|
513
|
+
}
|
|
514
|
+
| ValidationError
|
|
515
|
+
| UnauthorizedError
|
|
516
|
+
| ForbiddenError
|
|
517
|
+
| NotFoundError
|
|
518
|
+
| ConflictError
|
|
519
|
+
| PreconditionFailedError
|
|
520
|
+
| PreconditionRequiredError
|
|
521
|
+
| InternalServerError;
|
|
522
|
+
|
|
523
|
+
@doc("Delete one saved query.")
|
|
524
|
+
@useAuth(QylDeleteKeyAuth)
|
|
525
|
+
@delete
|
|
526
|
+
@route("/{saved_query_id}")
|
|
527
|
+
delete(
|
|
528
|
+
...ProjectScopeHeader,
|
|
529
|
+
...IfMatchHeader,
|
|
530
|
+
@path("saved_query_id") savedQueryId: ResourceId,
|
|
531
|
+
):
|
|
532
|
+
| NoContentResponse
|
|
533
|
+
| UnauthorizedError
|
|
534
|
+
| ForbiddenError
|
|
535
|
+
| NotFoundError
|
|
536
|
+
| PreconditionFailedError
|
|
537
|
+
| PreconditionRequiredError
|
|
538
|
+
| InternalServerError;
|
|
539
|
+
|
|
540
|
+
@doc("Evaluate a saved query against current telemetry. Read-only.")
|
|
541
|
+
@useAuth(QylApiKeyHeaderAuth)
|
|
542
|
+
@post
|
|
543
|
+
@route("/{saved_query_id}:run")
|
|
544
|
+
run(
|
|
545
|
+
...ProjectScopeHeader,
|
|
546
|
+
@path("saved_query_id") savedQueryId: ResourceId,
|
|
547
|
+
@body request: SavedQueryRunRequest,
|
|
548
|
+
):
|
|
549
|
+
| SavedQueryRunResult
|
|
550
|
+
| ValidationError
|
|
551
|
+
| UnauthorizedError
|
|
552
|
+
| NotFoundError
|
|
553
|
+
| InternalServerError;
|
|
554
|
+
}
|
|
555
|
+
|
|
268
556
|
// -----------------------------------------------------------------------------
|
|
269
557
|
// Health
|
|
270
558
|
// -----------------------------------------------------------------------------
|
package/api/runner.tsp
CHANGED
|
@@ -22,6 +22,7 @@ namespace Qyl.Api.Contracts.Runner {
|
|
|
22
22
|
message: RunnerLogLine,
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
@doc("Served only by the local qyl runner on a loopback (127.0.0.1) listener. It rejects every request whose remote address or Host is not loopback, and checks the Origin header only on the mutating restart and stop operations, so these operations carry no API-key authentication.")
|
|
25
26
|
@route("/runner/resources")
|
|
26
27
|
@tag("Runner resources")
|
|
27
28
|
interface RunnerResourcesApi {
|
|
@@ -81,3 +82,4 @@ namespace Qyl.Api.Contracts.Runner {
|
|
|
81
82
|
| InternalServerError;
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
|
package/common/auth.tsp
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Collector key security schemes per role
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Every collector key travels in `x-otlp-api-key`; its roles
|
|
5
|
+
// (Qyl.Api.Contracts.Common.CollectorKeyRole) decide what it may do. The query
|
|
6
|
+
// routes keep the original `ApiKeyAuth` scheme and require the `read` role;
|
|
7
|
+
// the schemes below name the roles the write routes require.
|
|
8
|
+
// =============================================================================
|
|
9
|
+
|
|
10
|
+
import "@typespec/http";
|
|
11
|
+
|
|
12
|
+
using TypeSpec.Http;
|
|
13
|
+
|
|
14
|
+
namespace TypeSpec.Http {
|
|
15
|
+
@doc("Collector API key in `x-otlp-api-key` carrying the `write` role (Qyl.Api.Contracts.Common.CollectorKeyRole). Creates and updates user resources.")
|
|
16
|
+
model QylWriteKeyAuth is ApiKeyAuth<ApiKeyLocation.header, "x-otlp-api-key">;
|
|
17
|
+
|
|
18
|
+
@doc("Collector API key in `x-otlp-api-key` carrying the `delete` role (Qyl.Api.Contracts.Common.CollectorKeyRole). Deletes user resources, never telemetry.")
|
|
19
|
+
model QylDeleteKeyAuth is ApiKeyAuth<ApiKeyLocation.header, "x-otlp-api-key">;
|
|
20
|
+
}
|
package/common/errors.tsp
CHANGED
|
@@ -130,6 +130,28 @@ model ConflictError extends ProblemDetails {
|
|
|
130
130
|
conflictingResource?: string;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
@doc("Precondition failed - the If-Match entity tag is no longer current (412)")
|
|
134
|
+
@error
|
|
135
|
+
model PreconditionFailedError extends ProblemDetails {
|
|
136
|
+
@statusCode _: 412;
|
|
137
|
+
title: "Precondition Failed";
|
|
138
|
+
|
|
139
|
+
@doc("Entity tag of the current version, when the caller may read it")
|
|
140
|
+
@encodedName("application/json", "current_etag")
|
|
141
|
+
currentEtag?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@doc("Precondition required - the request must send If-Match (428)")
|
|
145
|
+
@error
|
|
146
|
+
model PreconditionRequiredError extends ProblemDetails {
|
|
147
|
+
@statusCode _: 428;
|
|
148
|
+
title: "Precondition Required";
|
|
149
|
+
|
|
150
|
+
@doc("The header the request must carry")
|
|
151
|
+
@encodedName("application/json", "required_header")
|
|
152
|
+
requiredHeader?: "If-Match";
|
|
153
|
+
}
|
|
154
|
+
|
|
133
155
|
@doc("Rate limited - too many requests (429)")
|
|
134
156
|
@error
|
|
135
157
|
model RateLimitError extends ProblemDetails {
|
|
@@ -209,6 +231,12 @@ union ApiError {
|
|
|
209
231
|
@doc("Resource conflict")
|
|
210
232
|
conflict: ConflictError,
|
|
211
233
|
|
|
234
|
+
@doc("Entity tag no longer current")
|
|
235
|
+
preconditionFailed: PreconditionFailedError,
|
|
236
|
+
|
|
237
|
+
@doc("If-Match missing")
|
|
238
|
+
preconditionRequired: PreconditionRequiredError,
|
|
239
|
+
|
|
212
240
|
@doc("Rate limit exceeded")
|
|
213
241
|
rateLimit: RateLimitError,
|
|
214
242
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// Shared write base for user-created collector resources
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// Every resource a client creates through the collector API (annotations, saved
|
|
5
|
+
// queries) extends `ResourceBase`, and every write operation uses the headers
|
|
6
|
+
// below. Server-assigned members carry `Lifecycle.Read` visibility, so the
|
|
7
|
+
// create and update request models never accept them.
|
|
8
|
+
//
|
|
9
|
+
// Concurrency: a read returns `etag`; PATCH and DELETE send it back in
|
|
10
|
+
// `If-Match`. A missing `If-Match` is refused with 428, a stale one with 412.
|
|
11
|
+
// Retries: a POST that creates a resource takes `Idempotency-Key`; a repeat
|
|
12
|
+
// with the same key and body returns the first result instead of a duplicate.
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
import "@typespec/http";
|
|
16
|
+
import "./types.tsp";
|
|
17
|
+
|
|
18
|
+
using TypeSpec.Http;
|
|
19
|
+
|
|
20
|
+
namespace Qyl.Api.Contracts.Common;
|
|
21
|
+
|
|
22
|
+
@doc("Opaque, server-assigned identifier of a user-created collector resource.")
|
|
23
|
+
@minLength(1)
|
|
24
|
+
@maxLength(128)
|
|
25
|
+
scalar ResourceId extends string;
|
|
26
|
+
|
|
27
|
+
@doc("""
|
|
28
|
+
Opaque entity tag of one resource version. It changes on every successful
|
|
29
|
+
update; send it back in `If-Match` to update or delete that version.
|
|
30
|
+
""")
|
|
31
|
+
@minLength(1)
|
|
32
|
+
@maxLength(128)
|
|
33
|
+
scalar ETag extends string;
|
|
34
|
+
|
|
35
|
+
@doc("""
|
|
36
|
+
Opaque pagination cursor. Pass the `next_cursor` of one page to receive the
|
|
37
|
+
next page of the same query. Its content is not part of the contract and
|
|
38
|
+
must not be parsed or built by a client.
|
|
39
|
+
""")
|
|
40
|
+
scalar Cursor extends string;
|
|
41
|
+
|
|
42
|
+
@doc("""
|
|
43
|
+
Collector API key roles. Every key sent in `x-otlp-api-key` carries one or
|
|
44
|
+
more roles: `ingest` accepts OTLP, `read` serves the query routes, `write`
|
|
45
|
+
creates and updates user resources, and `delete` deletes them. Roles are
|
|
46
|
+
independent, so a leaked ingest key can neither read, change nor delete
|
|
47
|
+
anything.
|
|
48
|
+
""")
|
|
49
|
+
enum CollectorKeyRole {
|
|
50
|
+
@doc("Send OTLP traces, logs and metrics.")
|
|
51
|
+
ingest: "ingest",
|
|
52
|
+
|
|
53
|
+
@doc("Read telemetry and user resources.")
|
|
54
|
+
read: "read",
|
|
55
|
+
|
|
56
|
+
@doc("Create and update annotations and saved queries.")
|
|
57
|
+
write: "write",
|
|
58
|
+
|
|
59
|
+
@doc("Delete annotations and saved queries. Never deletes telemetry; retention does.")
|
|
60
|
+
delete: "delete",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@doc("Base of every user-created collector resource.")
|
|
64
|
+
model ResourceBase {
|
|
65
|
+
@doc("Server-assigned identifier.")
|
|
66
|
+
@visibility(Lifecycle.Read)
|
|
67
|
+
id: ResourceId;
|
|
68
|
+
|
|
69
|
+
@doc("When the resource was created.")
|
|
70
|
+
@visibility(Lifecycle.Read)
|
|
71
|
+
@encodedName("application/json", "created_at")
|
|
72
|
+
createdAt: utcDateTime;
|
|
73
|
+
|
|
74
|
+
@doc("When the resource was last changed.")
|
|
75
|
+
@visibility(Lifecycle.Read)
|
|
76
|
+
@encodedName("application/json", "updated_at")
|
|
77
|
+
updatedAt: utcDateTime;
|
|
78
|
+
|
|
79
|
+
@doc("Entity tag of this version; also served as the `ETag` response header.")
|
|
80
|
+
@visibility(Lifecycle.Read)
|
|
81
|
+
etag: ETag;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@doc("Retry key for a creating POST.")
|
|
85
|
+
model IdempotencyKeyHeader {
|
|
86
|
+
@doc("""
|
|
87
|
+
Client-chosen retry key, unique per intended create. A repeat with the same
|
|
88
|
+
key and body returns the first result; the same key with a different body
|
|
89
|
+
is refused with 409. Keys expire after 24 hours.
|
|
90
|
+
""")
|
|
91
|
+
@header("Idempotency-Key")
|
|
92
|
+
@minLength(1)
|
|
93
|
+
@maxLength(255)
|
|
94
|
+
idempotencyKey?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@doc("Optimistic-concurrency precondition for PATCH and DELETE.")
|
|
98
|
+
model IfMatchHeader {
|
|
99
|
+
@doc("""
|
|
100
|
+
The `etag` of the version being changed. A request without it is refused
|
|
101
|
+
with 428 Precondition Required; a request whose tag is no longer current is
|
|
102
|
+
refused with 412 Precondition Failed.
|
|
103
|
+
""")
|
|
104
|
+
@header("If-Match")
|
|
105
|
+
ifMatch: ETag;
|
|
106
|
+
}
|