qualink 0.1.0 → 0.2.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Allan Kimmer Jensen
3
+ Copyright (c) 2026 Allan Kimmer Jensen
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -71,6 +71,7 @@ If needed, you can still pass explicit values with `--repo`, `--branch`, `--comm
71
71
  Sink configuration:
72
72
 
73
73
  - `--sink elastic` (default) requires `ELASTIC_URL` and `ELASTIC_API_KEY`
74
+ - `--sink loki` requires `LOKI_URL`. Optional: `LOKI_USERNAME`, `LOKI_PASSWORD` (basic auth), `LOKI_TENANT_ID` (`X-Scope-OrgID` header for multi-tenant setups)
74
75
  - `--sink stdout` prints normalized documents for debugging
75
76
 
76
77
  Dry run mode:
@@ -59,6 +59,18 @@ export declare function createCollectorCommand<TExtra extends Record<string, unk
59
59
  "elastic-api-key": {
60
60
  readonly type: "string";
61
61
  };
62
+ "loki-url": {
63
+ readonly type: "string";
64
+ };
65
+ "loki-username": {
66
+ readonly type: "string";
67
+ };
68
+ "loki-password": {
69
+ readonly type: "string";
70
+ };
71
+ "loki-tenant-id": {
72
+ readonly type: "string";
73
+ };
62
74
  "retry-max": {
63
75
  readonly type: "string";
64
76
  };
@@ -47,6 +47,18 @@ export declare const biomeCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const coverageDotnetCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const coverageJsCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const eslintCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const lighthouseCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const metaCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -47,6 +47,18 @@ export declare const sarifCommand: import("citty").CommandDef<{
47
47
  "elastic-api-key": {
48
48
  readonly type: "string";
49
49
  };
50
+ "loki-url": {
51
+ readonly type: "string";
52
+ };
53
+ "loki-username": {
54
+ readonly type: "string";
55
+ };
56
+ "loki-password": {
57
+ readonly type: "string";
58
+ };
59
+ "loki-tenant-id": {
60
+ readonly type: "string";
61
+ };
50
62
  "retry-max": {
51
63
  readonly type: "string";
52
64
  };
@@ -15,6 +15,10 @@ export interface CommonArgs {
15
15
  collectorVersion?: unknown;
16
16
  elasticUrl?: unknown;
17
17
  elasticApiKey?: unknown;
18
+ lokiUrl?: unknown;
19
+ lokiUsername?: unknown;
20
+ lokiPassword?: unknown;
21
+ lokiTenantId?: unknown;
18
22
  retryMax?: unknown;
19
23
  retryBackoffMs?: unknown;
20
24
  allowEmpty?: unknown;
@@ -71,6 +75,18 @@ export declare const commonArgs: {
71
75
  readonly "elastic-api-key": {
72
76
  readonly type: "string";
73
77
  };
78
+ readonly "loki-url": {
79
+ readonly type: "string";
80
+ };
81
+ readonly "loki-username": {
82
+ readonly type: "string";
83
+ };
84
+ readonly "loki-password": {
85
+ readonly type: "string";
86
+ };
87
+ readonly "loki-tenant-id": {
88
+ readonly type: "string";
89
+ };
74
90
  readonly "retry-max": {
75
91
  readonly type: "string";
76
92
  };
@@ -37,6 +37,10 @@ export const commonArgs = {
37
37
  "collector-version": { type: "string" },
38
38
  "elastic-url": { type: "string" },
39
39
  "elastic-api-key": { type: "string" },
40
+ "loki-url": { type: "string" },
41
+ "loki-username": { type: "string" },
42
+ "loki-password": { type: "string" },
43
+ "loki-tenant-id": { type: "string" },
40
44
  "retry-max": { type: "string" },
41
45
  "retry-backoff-ms": { type: "string" },
42
46
  "allow-empty": { type: "boolean", default: false },
@@ -27,26 +27,43 @@ export async function sendToSink(metricType, args, documents) {
27
27
  return;
28
28
  }
29
29
  const sinkKindRaw = (envOrArg(argValue(args, "sink"), "QUALINK_SINK") ?? "elastic").toLowerCase();
30
- if (sinkKindRaw !== "elastic" && sinkKindRaw !== "stdout") {
31
- throw new CliError(`Unsupported sink '${sinkKindRaw}'. Expected elastic|stdout`, 2);
30
+ if (sinkKindRaw !== "elastic" && sinkKindRaw !== "loki" && sinkKindRaw !== "stdout") {
31
+ throw new CliError(`Unsupported sink '${sinkKindRaw}'. Expected elastic|loki|stdout`, 2);
32
32
  }
33
- const sinkKind = sinkKindRaw === "elastic" ? "elastic" : "stdout";
33
+ const sinkKind = sinkKindRaw;
34
34
  const retryMax = parseNumberInput(argValue(args, "retryMax", "retry-max"), 2);
35
35
  const retryBackoffMs = parseNumberInput(argValue(args, "retryBackoffMs", "retry-backoff-ms"), 500);
36
36
  const elasticUrl = envOrArg(argValue(args, "elasticUrl", "elastic-url"), "ELASTIC_URL");
37
37
  const elasticApiKey = envOrArg(argValue(args, "elasticApiKey", "elastic-api-key"), "ELASTIC_API_KEY");
38
+ const lokiUrl = envOrArg(argValue(args, "lokiUrl", "loki-url"), "LOKI_URL");
39
+ const lokiUsername = envOrArg(argValue(args, "lokiUsername", "loki-username"), "LOKI_USERNAME");
40
+ const lokiPassword = envOrArg(argValue(args, "lokiPassword", "loki-password"), "LOKI_PASSWORD");
41
+ const lokiTenantId = envOrArg(argValue(args, "lokiTenantId", "loki-tenant-id"), "LOKI_TENANT_ID");
38
42
  const sinkConfigBase = {
39
43
  kind: sinkKind,
40
44
  retryMax,
41
45
  retryBackoffMs,
42
46
  };
43
- const sinkConfig = sinkKind === "elastic"
44
- ? {
47
+ let sinkConfig;
48
+ if (sinkKind === "elastic") {
49
+ sinkConfig = {
45
50
  ...sinkConfigBase,
46
51
  ...(elasticUrl ? { elasticUrl } : {}),
47
52
  ...(elasticApiKey ? { elasticApiKey } : {}),
48
- }
49
- : sinkConfigBase;
53
+ };
54
+ }
55
+ else if (sinkKind === "loki") {
56
+ sinkConfig = {
57
+ ...sinkConfigBase,
58
+ ...(lokiUrl ? { lokiUrl } : {}),
59
+ ...(lokiUsername ? { lokiUsername } : {}),
60
+ ...(lokiPassword ? { lokiPassword } : {}),
61
+ ...(lokiTenantId ? { lokiTenantId } : {}),
62
+ };
63
+ }
64
+ else {
65
+ sinkConfig = sinkConfigBase;
66
+ }
50
67
  const sink = createSink(sinkConfig);
51
68
  await sink.send({
52
69
  metricType,
@@ -1,9 +1,13 @@
1
1
  import type { Sink } from "./types.js";
2
- export type SinkKind = "elastic" | "stdout";
2
+ export type SinkKind = "elastic" | "loki" | "stdout";
3
3
  export interface SinkConfig {
4
4
  kind: SinkKind;
5
5
  elasticUrl?: string;
6
6
  elasticApiKey?: string;
7
+ lokiUrl?: string;
8
+ lokiUsername?: string;
9
+ lokiPassword?: string;
10
+ lokiTenantId?: string;
7
11
  retryMax: number;
8
12
  retryBackoffMs: number;
9
13
  }
@@ -1,9 +1,23 @@
1
1
  import { ElasticSink } from "./elastic.js";
2
+ import { LokiSink } from "./loki.js";
2
3
  import { StdoutSink } from "./stdout.js";
3
4
  export function createSink(config) {
4
5
  if (config.kind === "stdout") {
5
6
  return new StdoutSink();
6
7
  }
8
+ if (config.kind === "loki") {
9
+ if (!config.lokiUrl) {
10
+ throw new Error("Loki sink requires LOKI_URL (or --loki-url)");
11
+ }
12
+ return new LokiSink({
13
+ url: config.lokiUrl,
14
+ username: config.lokiUsername,
15
+ password: config.lokiPassword,
16
+ tenantId: config.lokiTenantId,
17
+ retryMax: config.retryMax,
18
+ retryBackoffMs: config.retryBackoffMs,
19
+ });
20
+ }
7
21
  if (!config.elasticUrl || !config.elasticApiKey) {
8
22
  throw new Error("Elastic sink requires ELASTIC_URL and ELASTIC_API_KEY (or CLI overrides)");
9
23
  }
@@ -0,0 +1,30 @@
1
+ import type { NormalizedDocument } from "../types.js";
2
+ import type { SendInput, Sink } from "./types.js";
3
+ export interface LokiSinkOptions {
4
+ url: string;
5
+ username?: string | undefined;
6
+ password?: string | undefined;
7
+ tenantId?: string | undefined;
8
+ retryMax: number;
9
+ retryBackoffMs: number;
10
+ }
11
+ interface LokiStream {
12
+ stream: Record<string, string>;
13
+ values: [string, string][];
14
+ }
15
+ interface LokiPushPayload {
16
+ streams: LokiStream[];
17
+ }
18
+ export declare function toNanosecondEpoch(isoTimestamp: string): string;
19
+ export declare function buildLokiPayload(documents: NormalizedDocument[]): LokiPushPayload;
20
+ export declare class LokiSink implements Sink {
21
+ private readonly url;
22
+ private readonly username;
23
+ private readonly password;
24
+ private readonly tenantId;
25
+ private readonly retryMax;
26
+ private readonly retryBackoffMs;
27
+ constructor(options: LokiSinkOptions);
28
+ send(input: SendInput): Promise<void>;
29
+ }
30
+ export {};
@@ -0,0 +1,84 @@
1
+ function sleep(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
4
+ function isRetryableStatus(status) {
5
+ return status === 429 || status >= 500;
6
+ }
7
+ function toLabelKey(doc) {
8
+ return `${doc.metric_type}|${doc.repo}|${doc.environment}`;
9
+ }
10
+ export function toNanosecondEpoch(isoTimestamp) {
11
+ const ms = new Date(isoTimestamp).getTime();
12
+ return `${ms}000000`;
13
+ }
14
+ export function buildLokiPayload(documents) {
15
+ const streamMap = new Map();
16
+ for (const doc of documents) {
17
+ const key = toLabelKey(doc);
18
+ let stream = streamMap.get(key);
19
+ if (!stream) {
20
+ stream = {
21
+ stream: {
22
+ metric_type: doc.metric_type,
23
+ repo: doc.repo,
24
+ environment: doc.environment,
25
+ },
26
+ values: [],
27
+ };
28
+ streamMap.set(key, stream);
29
+ }
30
+ stream.values.push([toNanosecondEpoch(doc["@timestamp"]), JSON.stringify(doc)]);
31
+ }
32
+ return { streams: Array.from(streamMap.values()) };
33
+ }
34
+ export class LokiSink {
35
+ url;
36
+ username;
37
+ password;
38
+ tenantId;
39
+ retryMax;
40
+ retryBackoffMs;
41
+ constructor(options) {
42
+ this.url = options.url.replace(/\/$/, "");
43
+ this.username = options.username;
44
+ this.password = options.password;
45
+ this.tenantId = options.tenantId;
46
+ this.retryMax = options.retryMax;
47
+ this.retryBackoffMs = options.retryBackoffMs;
48
+ }
49
+ async send(input) {
50
+ if (input.documents.length === 0) {
51
+ return;
52
+ }
53
+ const payload = buildLokiPayload(input.documents);
54
+ const body = JSON.stringify(payload);
55
+ const headers = {
56
+ "Content-Type": "application/json",
57
+ };
58
+ if (this.username && this.password) {
59
+ const encoded = btoa(`${this.username}:${this.password}`);
60
+ headers.Authorization = `Basic ${encoded}`;
61
+ }
62
+ if (this.tenantId) {
63
+ headers["X-Scope-OrgID"] = this.tenantId;
64
+ }
65
+ let attempt = 0;
66
+ for (;;) {
67
+ attempt += 1;
68
+ const response = await fetch(`${this.url}/loki/api/v1/push`, {
69
+ method: "POST",
70
+ headers,
71
+ body,
72
+ });
73
+ if (response.status === 204 || response.ok) {
74
+ return;
75
+ }
76
+ const responseText = await response.text();
77
+ if (!isRetryableStatus(response.status) || attempt > this.retryMax) {
78
+ process.stderr.write(`[qualink] Dead-letter payload:\n${body}\n`);
79
+ throw new Error(`Loki push failed (${response.status}): ${responseText}`);
80
+ }
81
+ await sleep(this.retryBackoffMs * attempt);
82
+ }
83
+ }
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualink",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Collect, normalize, and relay code quality metrics from CI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,6 @@
47
47
  "access": "public"
48
48
  },
49
49
  "scripts": {
50
- "preinstall": "npx only-allow pnpm",
51
50
  "build": "tsc -p tsconfig.json",
52
51
  "start": "node ./bin/qualink.js",
53
52
  "dev": "node --loader ts-node/esm src/cli/index.ts",