Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): Add a CLI to build deep-links to Central ELK #36

Merged
merged 11 commits into from
Dec 19, 2023
Merged
12 changes: 12 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ on:
- main

jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: lint + compile
working-directory: cli
run: |
deno fmt --check
deno task compile
test:
runs-on: ubuntu-latest
steps:
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/release-cli.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Release CLI

on:
push:
tags:
- cli-v*
akash1810 marked this conversation as resolved.
Show resolved Hide resolved

jobs:
release:
runs-on: ubuntu-latest

permissions:
contents: write
packages: write

steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v1
with:
deno-version: v1.x
- name: lint + compile
working-directory: cli
run: |
deno fmt --check
deno task compile
- name: release
working-directory: cli/dist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
sha256sum devx-logs > checksum.txt
akash1810 marked this conversation as resolved.
Show resolved Hide resolved
gh release create ${{ github.ref }} * --generate-notes
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
61 changes: 61 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# DevX Logs CLI

A small tool to deep-link to Central ELK.

## Installation via homebrew

```bash
brew tap guardian/homebrew-devtools
brew install guardian/devtools/devx-logs

# update
brew upgrade devx-logs
```

## Usage

- Open the logs for Riff-Raff in PROD
```bash
devx-logs --space devx --stage PROD --app riff-raff
```
- Display the URL for logs from Riff-Raff in PROD
```bash
devx-logs --space devx --stage PROD --app riff-raff --no-follow
```
- Open the logs for Riff-Raff in PROD, where the level is INFO, and show the
message and logger_name columns
```bash
devx-logs --space devx --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name
```
- Open the logs for the repository 'guardian/prism':
```bash
devx-logs --filter gu:repo.keyword=guardian/prism --column message --column gu:repo
```

See all options via the `--help` flag:

```bash
devx-logs --help
```

## Releasing

Releasing is semi-automated. To release a new version, create a new tag with the
`cli-v` prefix:

```bash
git tag cli-v0.0.1
```

And then push the tag:

```bash
git push --tags
```

This will trigger [a GitHub Action](../.github/workflows/release-cli.yml),
publishing a new version to GitHub releases.

Once a new release is available, update the
[Homebrew formula](https://github.com/guardian/homebrew-devtools/blob/main/Formula/devx-logs.rb)
to point to the new version.
6 changes: 6 additions & 0 deletions cli/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"tasks": {
"compile": "deno compile --allow-run=open --target aarch64-apple-darwin --output dist/devx-logs index.ts",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only compile for M series macs, because as a department, that's what we use for local development.

Copy link
Member Author

@akash1810 akash1810 Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resulting binary is pretty big for what it does (>100MB). We could use bash instead of Deno, however we'd lose type-safety etc.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's because the Deno runtime has to be shipped within the binary. Can't be helped unfortunately (:

Copy link
Member

@AshCorr AshCorr Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're compiling to a specific arch anyway, is it worth using a compiled language like Rust or Go instead? Would likely cut down on the runtime size significantly.

Or compile to JS and run with the users existing node binaries?

Copy link
Member Author

@akash1810 akash1810 Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're compiling to a specific arch anyway, is it worth using a compiled language like Rust or Go instead? Would likely cut down on the runtime size significantly.

Chose TS on the assumption that it's the most familiar across the department (other than possibly Scala). If we move to JS, we could probably distribute this via NPM, rather than homebrew?

WDYT?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't worked with it much, but deno install could be a nice middle-ground, which wouldn't require much change to the code? https://docs.deno.com/runtime/manual/tools/script_installer

Copy link

@marsavar marsavar Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bryophyta my understanding is that install would create a script that invokes the CLI using deno. That means that people would have to have deno installed in order to run the CLI, since the runtime is not included.
I don't think we should require people to install additional software when running CLIs (which is why I'm personally averse to CLIs that need, for example, the JVM to run). I would rather have a 100MB standalone executable that's easy to distribute - I don't see it as different from a huge Electron app (which we've pretty much accepted as the norm).
As Ash says, there are languages there are better suited to writing CLIs, but Deno is a good compromise imho, since TypeScript is pretty much understood by everyone at the G.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We distribute our dev tooling via homebew. I'd be keen for this to also be distributed the same way, to reduce cognitive load. Homebrew formulae supports the idea of dependencies, so we could craft this such that the raw TS files form the Homebrew artifact, and we have a dependency on Deno. Theoretically this would provide the same benefit as distributing a binary - the reduction of "works on my machine" symptoms.

I'd suggest we ship as is, and revise our approach once we understand what to optimise for (disk usage, performance, maintainability, etc.). Our work laptops have quite a lot of storage (1TB), and I'd be surprised if ~100MB is noticeable.

WDYT?

"demo": "deno run --allow-run=open index.ts --space devx --stack deploy --stage PROD --app riff-raff"
}
}
18 changes: 18 additions & 0 deletions cli/deno.lock
akash1810 marked this conversation as resolved.
Show resolved Hide resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { Args } from "https://deno.land/[email protected]/flags/mod.ts";
import { parse } from "https://deno.land/[email protected]/flags/mod.ts";
import { open } from "https://deno.land/x/[email protected]/index.ts";

export function getLink(
space: string,
filters: Record<string, string>,
columns: string[],
): string {
const kibanaFilters = Object.entries(filters).map(([key, value]) => {
return `(query:(match_phrase:(${key}:'${value}')))`;
});

// The `#/` at the end is important for Kibana to correctly parse the query string
// The `URL` object moves this to the end of the string, which breaks the link.
const base = `https://logs.gutools.co.uk/s/${space}/app/discover#/`;

const query = {
...(kibanaFilters.length > 0 && {
_g: `(filters:!(${kibanaFilters.join(",")}))`,
}),
...(columns.length > 0 && {
_a: `(columns:!(${columns.join(",")}))`,
}),
};

const queryString = Object.entries(query)
.map(([key, value]) => `${key}=${value}`)
.join("&");

return `${base}?${queryString}`;
}
akash1810 marked this conversation as resolved.
Show resolved Hide resolved

function parseArguments(args: string[]): Args {
return parse(args, {
boolean: ["follow"],
negatable: ["follow"],
string: ["space", "stack", "stage", "app"],
collect: ["column", "filter"],
stopEarly: false,
"--": true,
default: {
follow: true,
column: ["message"],
space: "default",
filter: [],
},
});
}

function escapeColon(str: string): string {
return str.includes(":") ? `'${str}'` : str;
}

function parseFilters(filter: string[]): Record<string, string> {
return filter.reduce((acc, curr) => {
const [key, value] = curr.split("=");
return { ...acc, [escapeColon(key)]: value };
akash1810 marked this conversation as resolved.
Show resolved Hide resolved
}, {});
}

function removeUndefined(
obj: Record<string, string | undefined>,
): Record<string, string> {
return Object.entries(obj).filter(([, value]) => !!value).reduce(
(acc, [key, value]) => ({
...acc,
[key]: value,
}),
{},
);
}

function printHelp(): void {
console.log(`Usage: devx-logs [OPTIONS...]`);
console.log("\nOptional flags:");
console.log(" --help Display this help and exit");
console.log(" --space The Kibana space to use");
console.log(" --stack The stack tag to filter by");
console.log(" --stage The stage tag to filter by");
console.log(" --app The app tag to filter by");
console.log(
" --column Which columns to display. Multiple: true. Default: 'message'",
);
console.log(
" --filter Additional filters to apply. Multiple: true. Format: key=value",
);
console.log(" --no-follow Don't open the link in the browser");
console.log("\nExample:");
console.log(
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff",
);
console.log("\nAdvanced example:");
console.log(
" devx-logs --space devx --stack deploy --stage PROD --app riff-raff --filter level=INFO --filter region=eu-west-1 --column message --column logger_name",
);
}
akash1810 marked this conversation as resolved.
Show resolved Hide resolved

async function main(inputArgs: string[]) {
const args = parseArguments(inputArgs);

if (args.help) {
printHelp();
Deno.exit(0);
}

const { space, stack, stage, app, column, filter, follow } = args;

const mergedFilters: Record<string, string | undefined> = {
...parseFilters(filter),
"stack.keyword": stack,
"stage.keyword": stage,
"app.keyword": app,
};

const filters = removeUndefined(mergedFilters);
const link = getLink(space, filters, column.map(escapeColon));

console.log(link);

if (follow) {
await open(link);
akash1810 marked this conversation as resolved.
Show resolved Hide resolved
}
}

await main(Deno.args);
Loading