Skip to content

Commit 8a3cc09

Browse files
authored
Merge pull request #36 from guardian/aa/cli
feat(cli): Add a CLI to build deep-links to Central ELK
2 parents d55a126 + 824038d commit 8a3cc09

File tree

11 files changed

+378
-0
lines changed

11 files changed

+378
-0
lines changed

.github/workflows/ci.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ on:
1111
- main
1212

1313
jobs:
14+
cli:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: denoland/setup-deno@v1
19+
with:
20+
deno-version: v1.x
21+
- name: lint, test, compile
22+
working-directory: cli
23+
run: |
24+
deno fmt --check
25+
deno test
26+
deno task compile
1427
test:
1528
runs-on: ubuntu-latest
1629
steps:

.github/workflows/release-cli.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
name: Release CLI
2+
3+
on:
4+
push:
5+
tags:
6+
- cli-v*
7+
8+
jobs:
9+
release:
10+
runs-on: ubuntu-latest
11+
12+
permissions:
13+
contents: write
14+
packages: write
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: denoland/setup-deno@v1
19+
with:
20+
deno-version: v1.x
21+
- name: lint, test, compile
22+
working-directory: cli
23+
run: |
24+
deno fmt --check
25+
deno test
26+
deno task compile
27+
- name: release
28+
working-directory: cli/dist
29+
env:
30+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
31+
run: |
32+
sha256sum devx-logs > checksum.txt
33+
gh release create ${{ github.ref }} * --generate-notes

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist

cli/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# DevX Logs CLI
2+
3+
A small tool to deep-link to Central ELK.
4+
5+
## Installation via homebrew
6+
7+
```bash
8+
brew tap guardian/homebrew-devtools
9+
brew install guardian/devtools/devx-logs
10+
11+
# update
12+
brew upgrade devx-logs
13+
```
14+
15+
## Usage
16+
17+
- Open the logs for Riff-Raff in PROD
18+
```bash
19+
devx-logs --space devx --stage PROD --app riff-raff
20+
```
21+
- Display the URL for logs from Riff-Raff in PROD
22+
```bash
23+
devx-logs --space devx --stage PROD --app riff-raff --no-follow
24+
```
25+
- Open the logs for Riff-Raff in PROD, where the level is INFO, and show the
26+
message and logger_name columns
27+
```bash
28+
devx-logs --space devx --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name
29+
```
30+
- Open the logs for the repository 'guardian/prism':
31+
```bash
32+
devx-logs --filter gu:repo.keyword=guardian/prism --column message --column gu:repo
33+
```
34+
35+
See all options via the `--help` flag:
36+
37+
```bash
38+
devx-logs --help
39+
```
40+
41+
## Releasing
42+
43+
Releasing is semi-automated. To release a new version, create a new tag with the
44+
`cli-v` prefix:
45+
46+
```bash
47+
git tag cli-v0.0.1
48+
```
49+
50+
And then push the tag:
51+
52+
```bash
53+
git push --tags
54+
```
55+
56+
This will trigger [a GitHub Action](../.github/workflows/release-cli.yml),
57+
publishing a new version to GitHub releases.
58+
59+
Once a new release is available, update the
60+
[Homebrew formula](https://github.com/guardian/homebrew-devtools/blob/main/Formula/devx-logs.rb)
61+
to point to the new version.

cli/deno.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"tasks": {
3+
"compile": "deno compile --allow-run=open --target aarch64-apple-darwin --output dist/devx-logs main.ts",
4+
"demo": "deno run --allow-run=open main.ts --space devx --stack deploy --stage PROD --app riff-raff"
5+
}
6+
}

cli/deno.lock

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/elk.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Wrap a string in single quotes so Kibana can parse it correctly
3+
*/
4+
function wrapString(str: string): string {
5+
return `'${str}'`;
6+
}
7+
8+
export function getLink(
9+
space: string,
10+
filters: Record<string, string>,
11+
columns: string[] = [],
12+
): string {
13+
const kibanaFilters = Object.entries(filters).map(([key, value]) => {
14+
return `(query:(match_phrase:(${wrapString(key)}:${wrapString(value)})))`;
15+
});
16+
17+
// The `#/` at the end is important for Kibana to correctly parse the query string
18+
// The `URL` object moves this to the end of the string, which breaks the link.
19+
const base = `https://logs.gutools.co.uk/s/${space}/app/discover#/`;
20+
21+
const query = {
22+
...(kibanaFilters.length > 0 && {
23+
_g: `(filters:!(${kibanaFilters.join(",")}))`,
24+
}),
25+
...(columns.length > 0 && {
26+
_a: `(columns:!(${columns.map(wrapString).join(",")}))`,
27+
}),
28+
};
29+
30+
const queryString = Object.entries(query)
31+
.map(([key, value]) => `${key}=${value}`)
32+
.join("&");
33+
34+
return `${base}?${queryString}`;
35+
}

cli/elk_test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { assertEquals } from "https://deno.land/std@0.208.0/assert/mod.ts";
2+
import { getLink } from "./elk.ts";
3+
4+
// NOTE: Each of these URLs should be opened in a browser to verify that they work as expected.
5+
6+
Deno.test("getLink with simple input", () => {
7+
const got = getLink("devx", { app: "riff-raff", stage: "PROD" });
8+
const want =
9+
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('app':'riff-raff'))),(query:(match_phrase:('stage':'PROD')))))";
10+
assertEquals(got, want);
11+
});
12+
13+
Deno.test("getLink with columns", () => {
14+
const got = getLink("devx", { app: "riff-raff", stage: "PROD" }, [
15+
"message",
16+
"level",
17+
]);
18+
const want =
19+
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('app':'riff-raff'))),(query:(match_phrase:('stage':'PROD')))))&_a=(columns:!('message','level'))";
20+
assertEquals(got, want);
21+
});
22+
23+
/*
24+
Filters and columns with colon(:) input should get wrapped in single quotes(') so that Kibana can parse them correctly.
25+
That is, gu:repo should become 'gu:repo'.
26+
*/
27+
Deno.test("getLink with colon(:) input", () => {
28+
const got = getLink("devx", {
29+
"gu:repo.keyword": "guardian/amigo",
30+
stage: "PROD",
31+
}, ["message", "gu:repo"]);
32+
const want =
33+
"https://logs.gutools.co.uk/s/devx/app/discover#/?_g=(filters:!((query:(match_phrase:('gu:repo.keyword':'guardian/amigo'))),(query:(match_phrase:('stage':'PROD')))))&_a=(columns:!('message','gu:repo'))";
34+
assertEquals(got, want);
35+
});

cli/main.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Args } from "https://deno.land/std@0.200.0/flags/mod.ts";
2+
import { parse } from "https://deno.land/std@0.200.0/flags/mod.ts";
3+
import { getLink } from "./elk.ts";
4+
import { parseFilters, removeUndefined } from "./transform.ts";
5+
6+
function parseArguments(args: string[]): Args {
7+
return parse(args, {
8+
boolean: ["follow"],
9+
negatable: ["follow"],
10+
string: ["space", "stack", "stage", "app"],
11+
collect: ["column", "filter"],
12+
stopEarly: false,
13+
"--": true,
14+
default: {
15+
follow: true,
16+
column: ["message"],
17+
space: "default",
18+
filter: [],
19+
},
20+
});
21+
}
22+
23+
function printHelp(): void {
24+
console.log(`Usage: devx-logs [OPTIONS...]`);
25+
console.log("\nOptional flags:");
26+
console.log(" --help Display this help and exit");
27+
console.log(" --space The Kibana space to use");
28+
console.log(" --stack The stack tag to filter by");
29+
console.log(" --stage The stage tag to filter by");
30+
console.log(" --app The app tag to filter by");
31+
console.log(
32+
" --column Which columns to display. Multiple: true. Default: 'message'",
33+
);
34+
console.log(
35+
" --filter Additional filters to apply. Multiple: true. Format: key=value",
36+
);
37+
console.log(" --no-follow Don't open the link in the browser");
38+
console.log("\nExample:");
39+
console.log(
40+
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff",
41+
);
42+
console.log("\nAdvanced example:");
43+
console.log(
44+
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name",
45+
);
46+
}
47+
48+
function main(inputArgs: string[]) {
49+
const args = parseArguments(inputArgs);
50+
51+
if (args.help) {
52+
printHelp();
53+
Deno.exit(0);
54+
}
55+
56+
const { space, stack, stage, app, column, filter, follow } = args;
57+
58+
const mergedFilters: Record<string, string | undefined> = {
59+
...parseFilters(filter),
60+
"stack.keyword": stack,
61+
"stage.keyword": stage,
62+
"app.keyword": app,
63+
};
64+
65+
const filters = removeUndefined(mergedFilters);
66+
const link = getLink(space, filters, column);
67+
68+
console.log(link);
69+
70+
if (follow) {
71+
new Deno.Command("open", { args: [link] }).spawn();
72+
}
73+
}
74+
75+
// Learn more at https://deno.land/manual/examples/module_metadata#concepts
76+
if (import.meta.main) {
77+
main(Deno.args);
78+
}

cli/transform.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Turn an array of strings of form `key=value` into an object of form `{ key: value }`
3+
*/
4+
export function parseFilters(filter: string[]): Record<string, unknown> {
5+
return filter.reduce((acc, curr) => {
6+
const [key, value] = curr.split("=");
7+
return { ...acc, [key]: value };
8+
}, {});
9+
}
10+
11+
/**
12+
* Remove keys from a `Record` whose value is falsy
13+
*/
14+
export function removeUndefined(
15+
obj: Record<string, string | undefined>,
16+
): Record<string, string> {
17+
return Object.entries(obj).filter(([, value]) => !!value).reduce(
18+
(acc, [key, value]) => ({
19+
...acc,
20+
[key]: value,
21+
}),
22+
{},
23+
);
24+
}

cli/transform_test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { assertEquals } from "https://deno.land/std@0.208.0/assert/assert_equals.ts";
2+
import { parseFilters, removeUndefined } from "./transform.ts";
3+
4+
Deno.test("parseFilters", () => {
5+
const got = parseFilters(["stack=deploy", "stage=PROD", "app=riff-raff"]);
6+
const want = {
7+
stack: "deploy",
8+
stage: "PROD",
9+
app: "riff-raff",
10+
};
11+
assertEquals(got, want);
12+
});
13+
14+
Deno.test("parseFilters without an = delimiter", () => {
15+
const got = parseFilters(["message"]);
16+
const want = {
17+
message: undefined,
18+
};
19+
assertEquals(got, want);
20+
});
21+
22+
Deno.test("parseFilters without a value on the RHS of =", () => {
23+
const got = parseFilters(["name="]);
24+
const want = {
25+
name: "",
26+
};
27+
assertEquals(got, want);
28+
});
29+
30+
Deno.test("removeUndefined", () => {
31+
const got = removeUndefined({
32+
stack: "deploy",
33+
stage: undefined,
34+
app: "riff-raff",
35+
team: "",
36+
});
37+
const want = {
38+
stack: "deploy",
39+
app: "riff-raff",
40+
};
41+
assertEquals(got, want);
42+
});
43+
44+
Deno.test("removeUndefined where the RHS is 0", () => {
45+
const got = removeUndefined({
46+
errors: "0",
47+
});
48+
const want = {
49+
errors: "0",
50+
};
51+
assertEquals(got, want);
52+
});

0 commit comments

Comments
 (0)