Repository: DanielMSchmidt/apollo-opentracing
Branch: master
Commit: f967b17736e4
Files: 18
Total size: 42.8 KB
Directory structure:
gitextract___y3lay0/
├── .all-contributorsrc
├── .github/
│ ├── stale.yml
│ └── workflows/
│ ├── pr.yml
│ └── release.yml
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── commitlint.config.js
├── package.json
├── renovate.json
├── src/
│ ├── __tests__/
│ │ ├── __snapshots__/
│ │ │ └── integration-test.ts.snap
│ │ └── integration-test.ts
│ ├── context.ts
│ ├── index.ts
│ └── test/
│ ├── span-serializer.ts
│ └── types.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .all-contributorsrc
================================================
{
"projectName": "apollo-opentracing",
"projectOwner": "DanielMSchmidt",
"repoType": "github",
"repoHost": "https://github.com",
"files": [
"README.md"
],
"imageSize": 100,
"commit": true,
"contributors": [
{
"login": "DanielMSchmidt",
"name": "Daniel Schmidt",
"avatar_url": "https://avatars2.githubusercontent.com/u/1337046?v=4",
"profile": "http://danielmschmidt.de/",
"contributions": [
"code",
"ideas"
]
},
{
"login": "cliedeman",
"name": "Ciaran Liedeman",
"avatar_url": "https://avatars2.githubusercontent.com/u/3578740?v=4",
"profile": "https://github.com/cliedeman",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "Multiply",
"name": "Jens Ulrich Hjuler Pedersen",
"avatar_url": "https://avatars3.githubusercontent.com/u/453031?v=4",
"profile": "http://juhp.net",
"contributions": [
"bug",
"ideas",
"review"
]
},
{
"login": "frances3006",
"name": "Francesca",
"avatar_url": "https://avatars0.githubusercontent.com/u/9115596?v=4",
"profile": "https://github.com/frances3006",
"contributions": [
"code"
]
},
{
"login": "ricardocasares",
"name": "Ricardo Casares",
"avatar_url": "https://avatars2.githubusercontent.com/u/84963?v=4",
"profile": "https://analogic.al",
"contributions": [
"code"
]
},
{
"login": "mwieczorek",
"name": "Michał Wieczorek",
"avatar_url": "https://avatars2.githubusercontent.com/u/7051680?v=4",
"profile": "https://keybase.io/mwieczorek",
"contributions": [
"code"
]
},
{
"login": "koenpunt",
"name": "Koen Punt",
"avatar_url": "https://avatars2.githubusercontent.com/u/351038?v=4",
"profile": "https://koen.pt",
"contributions": [
"code"
]
},
{
"login": "zekenie",
"name": "Zeke Nierenberg",
"avatar_url": "https://avatars2.githubusercontent.com/u/962281?v=4",
"profile": "https://github.com/zekenie",
"contributions": [
"code"
]
},
{
"login": "voslartomas",
"name": "Tomáš Voslař",
"avatar_url": "https://avatars3.githubusercontent.com/u/1945040?v=4",
"profile": "https://app.sport-buddy.net",
"contributions": [
"code"
]
},
{
"login": "benkimball",
"name": "Ben Kimball",
"avatar_url": "https://avatars2.githubusercontent.com/u/40365?v=4",
"profile": "http://iam.benkimball.com/",
"contributions": [
"code"
]
},
{
"login": "liangjiapei",
"name": "Jiapei Liang",
"avatar_url": "https://avatars.githubusercontent.com/u/9281185?v=4",
"profile": "https://jiapei.io/",
"contributions": [
"code"
]
},
{
"login": "RichardWright",
"name": "Richard W",
"avatar_url": "https://avatars.githubusercontent.com/u/881815?v=4",
"profile": "https://github.com/RichardWright",
"contributions": [
"ideas",
"research"
]
},
{
"login": "leeweisberger",
"name": "Lee Weisberger",
"avatar_url": "https://avatars.githubusercontent.com/u/6363419?v=4",
"profile": "https://github.com/leeweisberger",
"contributions": [
"code"
]
},
{
"login": "StarpTech",
"name": "Dustin Deus",
"avatar_url": "https://avatars.githubusercontent.com/u/1764424?v=4",
"profile": "https://starptech.de/",
"contributions": [
"bug"
]
}
],
"commitConvention": "none"
}
================================================
FILE: .github/stale.yml
================================================
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
================================================
FILE: .github/workflows/pr.yml
================================================
name: Node.js CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [15.x, 16.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
push:
branches:
- master
- main
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 16
- name: Install dependencies
run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release
================================================
FILE: .gitignore
================================================
dist/
node_modules/
================================================
FILE: .npmignore
================================================
*
!src/**/*
!dist/**/*
dist/**/*.test.*
!package.json
!README.md
================================================
FILE: LICENSE
================================================
The MIT License (MIT)
Copyright (c) 2020 Daniel M. Schmidt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Apollo Opentracing [](https://badge.fury.io/js/apollo-opentracing) [](https://travis-ci.com/DanielMSchmidt/apollo-opentracing) [](https://github.com/semantic-release/semantic-release) [](#contributors)
Apollo Opentracing allows you to integrate open source baked performance tracing to your Apollo server based on industry standards for tracing.
- 🚀 Request & Field level resolvers are traced out of the box
- 🔍 Queries and results are logged, to make debugging easier
- ⚙️ Select which requests you want to trace
- 🔗 Spans transmitted through the HTTP Headers are picked up
- 🔧 Use the opentracing compatible tracer you like, e.g.
- [jaeger](https://www.jaegertracing.io/)
- [zipkin](https://github.com/DanielMSchmidt/zipkin-javascript-opentracing)
- 🦖 Support from node 6 on
## Installation
Run `npm install --save apollo-opentracing` given that you already setup an opentracing tracer accordingly.
## Setup
We need two types of tracer (which could be identical if you like):
- server: Only used for the root (the first span we will start)
- local: Used to start every other span
```diff
const { graphqlExpress } = require("apollo-server-express");
const {serverTracer, localTracer} = require("./tracer");
+const OpentracingPlugin = require("apollo-opentracing").default;
app.use(
"/graphql",
bodyParser.json(),
graphqlExpress({
schema,
+ plugins: [OpentracingPlugin({
+ server: serverTracer,
+ local: localTracer,
+ })]
})
)
```
## Connecting Services

To connect other services you need to use the opentracing [inject](http://opentracing.io/documentation/pages/api/cross-process-tracing.html) function of your tracer.
We pass the current span down to your resolvers as `info.span`, so you should use it.
You can also make use of it and add new logs or tags on the fly if you like.
This may look something like this:
```js
myFieldResolver(source, args, context, info) {
const headers = {...};
const parentSpan = info.span;
// please use the same tracer you passed down to the extension
const networkSpan = tracer.startSpan("NetworkRequest:" + endpoint, {
childOf: parentSpan
});
// Let's transfer the span information to the headers
tracer.inject(
networkSpan,
YourOpentracingImplementation.FORMAT_HTTP_HEADERS,
headers
);
return doNetworkRequest(endpoint, headers).then(result => {
networkSpan.finish()
return result;
}, err => {
networkSpan.log({
error: true,
errorMessage: err
});
networkSpan.finish();
return err;
});
}
```
## Selective Tracing
Sometimes you don't want to trace everything, so we provide ways to select if you want to start a span right now or not.
### By Request
If you construct the extension with `shouldTraceRequest` you get the option to opt-in or out on a request basis.
When you don't start the span for the request the field resolvers will also not be used.
The function is called with the same arguments as the `requestDidStart` function extensions can provide, which is documented [here](https://github.com/apollographql/apollo-server/blob/master/packages/graphql-extensions/src/index.ts#L35).
When the request is not traced there will also be no traces of the field resolvers.
### By Field
There might be certain field resolvers that are not worth the tracing, e.g. when they get a value out of an object and need no further tracing. To control if you want a field resolver to be traced you can pass the `shouldTraceFieldResolver` option to the constructor. The function is called with the same arguments as your field resolver and you can get the name of the field by `info.fieldName`. When you return false no traces will be made of this field resolvers and all underlying ones.
## Modifying span metadata
If you'd like to add custom tags or logs to span you can construct the extension with `onRequestResolve`. The function is called with two arguments: span and infos `onRequestResolve?: (span: Span, info: RequestStart)`
## Using your own request span
If you need to take control of initializing the request span (e.g because you need to use it during context initialization) you can do so by having creating it as `context.requestSpan`.
## Options
- `server`: Opentracing Tracer for the incoming request
- `local`: Opentracing Tracer for the local and outgoing requests
- `onFieldResolve(source: any, args: { [argName: string]: any }, context: SpanContext, info: GraphQLResolveInfo)`: Allow users to add extra information to the span
- `onFieldResolveFinish(error: Error | null, result: any, span: Span)`: Callback after a field was resolved
- `shouldTraceRequest` & `shouldTraceFieldResolver`: See [Selective Tracing](#selective-tracing)
- `onRequestResolve(span: Span, info: GraphQLRequestContext)`: Add extra information to the request span
- `createCustomSpanName(name: String, info: GraphQLResolveInfo)`: Allow users to provide customized span name
- `onRequestError(rootSpan: Span, info: GraphQLRequestContextDidEncounterErrors)`: Callback when a request errors
## Contributing
Please feel free to add issues with new ideas, bugs and anything that might come up.
Let's make performance measurement to everyone <3
## Contributors
Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="http://danielmschmidt.de/"><img src="https://avatars2.githubusercontent.com/u/1337046?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Daniel Schmidt</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=DanielMSchmidt" title="Code">💻</a> <a href="#ideas-DanielMSchmidt" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/cliedeman"><img src="https://avatars2.githubusercontent.com/u/3578740?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ciaran Liedeman</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/issues?q=author%3Acliedeman" title="Bug reports">🐛</a> <a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=cliedeman" title="Code">💻</a> <a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=cliedeman" title="Tests">⚠️</a></td>
<td align="center"><a href="http://juhp.net"><img src="https://avatars3.githubusercontent.com/u/453031?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jens Ulrich Hjuler Pedersen</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/issues?q=author%3AMultiply" title="Bug reports">🐛</a> <a href="#ideas-Multiply" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/DanielMSchmidt/apollo-opentracing/pulls?q=is%3Apr+reviewed-by%3AMultiply" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/frances3006"><img src="https://avatars0.githubusercontent.com/u/9115596?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Francesca</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=frances3006" title="Code">💻</a></td>
<td align="center"><a href="https://analogic.al"><img src="https://avatars2.githubusercontent.com/u/84963?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo Casares</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=ricardocasares" title="Code">💻</a></td>
<td align="center"><a href="https://keybase.io/mwieczorek"><img src="https://avatars2.githubusercontent.com/u/7051680?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michał Wieczorek</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=mwieczorek" title="Code">💻</a></td>
<td align="center"><a href="https://koen.pt"><img src="https://avatars2.githubusercontent.com/u/351038?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Koen Punt</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=koenpunt" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/zekenie"><img src="https://avatars2.githubusercontent.com/u/962281?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Zeke Nierenberg</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=zekenie" title="Code">💻</a></td>
<td align="center"><a href="https://app.sport-buddy.net"><img src="https://avatars3.githubusercontent.com/u/1945040?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tomáš Voslař</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=voslartomas" title="Code">💻</a></td>
<td align="center"><a href="http://iam.benkimball.com/"><img src="https://avatars2.githubusercontent.com/u/40365?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ben Kimball</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=benkimball" title="Code">💻</a></td>
<td align="center"><a href="https://jiapei.io/"><img src="https://avatars.githubusercontent.com/u/9281185?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jiapei Liang</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=liangjiapei" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/RichardWright"><img src="https://avatars.githubusercontent.com/u/881815?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Richard W</b></sub></a><br /><a href="#ideas-RichardWright" title="Ideas, Planning, & Feedback">🤔</a> <a href="#research-RichardWright" title="Research">🔬</a></td>
<td align="center"><a href="https://github.com/leeweisberger"><img src="https://avatars.githubusercontent.com/u/6363419?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Lee Weisberger</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/commits?author=leeweisberger" title="Code">💻</a></td>
<td align="center"><a href="https://starptech.de/"><img src="https://avatars.githubusercontent.com/u/1764424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dustin Deus</b></sub></a><br /><a href="https://github.com/DanielMSchmidt/apollo-opentracing/issues?q=author%3AStarpTech" title="Bug reports">🐛</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
## License
[MIT](LICENSE)
================================================
FILE: commitlint.config.js
================================================
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: {
"scope-case": [0],
},
};
================================================
FILE: package.json
================================================
{
"name": "apollo-opentracing",
"version": "0.0.0-development",
"description": "Trace your GraphQL server with Opentracing",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"clean": "rm -rf dist",
"compile": "tsc",
"prepare": "npm run clean && npm run compile",
"test": "jest",
"semantic-release": "semantic-release",
"travis-deploy-once": "travis-deploy-once --pro",
"contributors:add": "all-contributors add",
"contributors:generate": "all-contributors generate"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
]
},
"repository": "DanielMSchmidt/apollo-opentracing",
"author": "Daniel Schmidt <danielmschmidt92@gmail.com>",
"license": "MIT",
"peerDependencies": {
"apollo-server": ">=3.0.0",
"apollo-server-env": "*",
"graphql": ">=0.10.x",
"opentracing": "*"
},
"dependencies": {
"apollo-server-plugin-base": "^3.0.0",
"apollo-server-types": "^3.0.0"
},
"devDependencies": {
"@commitlint/config-conventional": "11.0.0",
"@commitlint/travis-cli": "17.8.1",
"@types/jest": "26.0.24",
"@types/node": "14.18.63",
"@types/supertest": "2.0.16",
"all-contributors-cli": "6.26.1",
"apollo-server": "3.13.0",
"apollo-server-env": "4.2.1",
"express": "4.22.1",
"graphql": "15.10.1",
"graphql-tools": "6.2.6",
"jest": "27.5.1",
"opentracing": "0.14.7",
"semantic-release": "20.1.3",
"supertest": "5.0.0",
"travis-deploy-once": "5.0.11",
"ts-jest": "27.0.4",
"typescript": "4.9.5"
}
}
================================================
FILE: renovate.json
================================================
{
"extends": ["config:base", ":semanticCommits"],
"automerge": true,
"commitMessagePrefix": "fix:",
"major": {
"automerge": false
}
}
================================================
FILE: src/__tests__/__snapshots__/integration-test.ts.snap
================================================
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`integration with apollo-server alias with fragment works 1`] = `
request:1
finished: true
+-- a:2
finished: true
+-- dos:3
finished: true
`;
exports[`integration with apollo-server alias works 1`] = `
request:1
finished: true
+-- a:2
finished: true
+-- uno:3
finished: true
+-- two:4
finished: true
`;
exports[`integration with apollo-server correct span nesting 1`] = `
request:1
finished: true
+-- a:2
finished: true
+-- one:3
finished: true
+-- two:4
finished: true
`;
exports[`integration with apollo-server does not start a field resolver span if the parent field resolver was not traced 1`] = `
request:1
finished: true
+-- b:2
finished: true
+-- four:3
finished: true
`;
exports[`integration with apollo-server implements traces for arrays 1`] = `
request:1
finished: true
+-- as:2
finished: true
+-- one:3
finished: true
+-- two:4
finished: true
+-- one:5
finished: true
+-- two:6
finished: true
+-- one:7
finished: true
+-- two:8
finished: true
`;
exports[`integration with apollo-server onFieldResolve & onFieldResolveFinish 1`] = `
request:1
finished: true
+-- a:2
finished: true
logs:
1. {"onFieldResolve":"yes"}
2. {"onFieldResolveFinish":"yes"}
+-- one:3
finished: true
logs:
1. {"onFieldResolve":"yes"}
2. {"onFieldResolveFinish":"yes"}
+-- two:4
finished: true
logs:
1. {"onFieldResolve":"yes"}
2. {"onFieldResolveFinish":"yes"}
+-- b:5
finished: true
logs:
1. {"onFieldResolve":"yes"}
2. {"onFieldResolveFinish":"yes"}
+-- four:6
finished: true
logs:
1. {"onFieldResolve":"yes"}
2. {"onFieldResolveFinish":"yes"}
`;
exports[`integration with apollo-server onRequestError 1`] = `
request:1
finished: true
logs:
1. {"onRequestError":"yes"}
+-- e:2
finished: true
`;
exports[`integration with apollo-server onRequestResolve 1`] = `
request:1
finished: true
logs:
1. {"onRequestResolve":"yes"}
+-- a:2
finished: true
+-- one:3
finished: true
+-- two:4
finished: true
+-- b:5
finished: true
+-- four:6
finished: true
`;
exports[`integration with apollo-server picks up external spans 1`] = `
external:-1
finished: false
+-- request:1
finished: true
+-- a:2
finished: true
+-- one:3
finished: true
`;
================================================
FILE: src/__tests__/integration-test.ts
================================================
import * as express from "express";
import * as request from "supertest";
import { ApolloServer } from "apollo-server-express";
import { Tracer } from "opentracing";
import { MockSpan, MockSpanTree } from "../test/types";
import spanSerializer from "../test/span-serializer";
import ApolloOpentracing, { InitOptions, SpanContext } from "../";
expect.addSnapshotSerializer(spanSerializer);
let mockSpanId = 1;
// Stable spanId's
beforeEach(() => {
mockSpanId = 1;
});
const buildSpanTree = (spans: MockSpan[]) => {
// TODO we currently assume there is only one null parent entry.
const rootSpan = spans.find((span) => !span.parentId);
if (!rootSpan) {
throw new Error("No root span found");
}
const spansByParentId = spans.reduce((acc, span) => {
// Check for root
if (span.parentId) {
if (acc.has(span.parentId)) {
acc.get(span.parentId)?.push(span);
} else {
acc.set(span.parentId, [span]);
}
}
return acc;
}, new Map<number, MockSpan[]>());
expect(rootSpan).toBeDefined();
const tree: MockSpanTree = {
...rootSpan,
children: [],
};
buildTree(tree, spansByParentId);
// Lost Spans
expect(spansByParentId.size).toBe(0);
return tree;
};
const buildTree = (
parent: MockSpanTree,
spansByParentId: Map<number, MockSpan[]>
) => {
if (spansByParentId.has(parent.id)) {
const spans = spansByParentId.get(parent.id);
if (!spans) {
throw new Error(
"Could not find the spans for parent " +
parent.name +
" with id " +
parent.id
);
}
spansByParentId.delete(parent.id);
// TODO: do we need to sort?
for (const span of spans) {
const node = {
...span,
children: [],
};
parent.children.push(node);
buildTree(node, spansByParentId);
}
}
};
function buildEmptySpan(
id: number,
name: string,
parentId?: number,
options?: any
) {
return {
id,
parentId,
name,
options,
logs: [],
tags: [],
finished: false,
};
}
class MockTracer {
spans: MockSpan[];
constructor() {
this.spans = [];
}
extract(_idk: any, header: Record<string, string>) {
// we use this as a name and -1 as id
const externalSpanId = header["x-b3-spanid"];
if (!externalSpanId) {
return null;
}
const externalSpan = buildEmptySpan(-1, externalSpanId);
this.spans.push(externalSpan);
return externalSpan;
}
startSpan(name: string, options: any) {
const spanId = mockSpanId++;
this.spans.push(
buildEmptySpan(spanId, name, options?.childOf?.id, options)
);
const self = this;
return {
log(object: any) {
self.spans.find((span) => span.id === spanId)?.logs.push(object);
},
setTag(key: string, value: any) {
self.spans
.find((span) => span.id === spanId)
?.tags.push({ key: key, value: value });
},
id: spanId,
// Added for debugging
name,
finish() {
const span = self.spans.find((span) => span.id === spanId);
if (span) {
span.finished = true;
}
},
};
}
}
async function createApp<InstanceContext extends SpanContext>({
tracer,
...params
}: { tracer: MockTracer } & Omit<
InitOptions<InstanceContext>,
"server" | "local"
>) {
const app = express();
const server = new ApolloServer({
typeDefs: `
type A {
one: String
two: String
three: [B]
}
type B {
four: String
}
type Query {
a: A
b: B
e: B
as: [A]
bs: [B]
}
`,
resolvers: {
Query: {
a() {
return {
one: "1",
two: "2",
three: [{ four: "4" }, { four: "IV" }],
};
},
b() {
return {
four: "4",
};
},
e() {
return new Error("error!");
},
as() {
return [
{
one: "1",
two: "2",
},
{
one: "I",
two: "II",
},
{
one: "eins",
two: "zwei",
},
];
},
},
},
plugins: [
ApolloOpentracing({
...params,
server: tracer as unknown as Tracer,
local: tracer as unknown as Tracer,
}),
],
});
await server.start();
server.applyMiddleware({ app });
return app;
}
describe("integration with apollo-server", () => {
it("closes all spans", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
}
}`,
})
.expect(200);
expect(tracer.spans.length).toBe(3);
expect(tracer.spans.filter((span) => span.finished).length).toBe(3);
});
it("correct span nesting", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
two
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
it("does not start a field resolver span if the parent field resolver was not traced", async () => {
const tracer = new MockTracer();
const shouldTraceFieldResolver = (
_source: any,
_args: any,
_ctx: any,
info: any
) => {
if (info.fieldName === "a") {
return false;
}
return true;
};
const app = await createApp({ tracer, shouldTraceFieldResolver });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
two
}
b {
four
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
it("implements traces for arrays", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
as {
one
two
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
it("alias works", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
uno: one
two
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
it("alias with fragment works", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `
fragment F on A {
dos: two
}
query {
a {
...F
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
it("onFieldResolve & onFieldResolveFinish", async () => {
const tracer = new MockTracer();
const onFieldResolve = jest.fn(
(_s: any, _args: any, _context: any, info: any) => {
info.span.log({ onFieldResolve: "yes" });
}
);
const onFieldResolveFinish = jest.fn(
(_err: any, _result: any, span: any) => {
span.log({ onFieldResolveFinish: "yes" });
}
);
const app = await createApp({
tracer,
onFieldResolve,
onFieldResolveFinish,
});
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
two
}
b {
four
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
expect(onFieldResolve).toHaveBeenCalledTimes(5);
expect(onFieldResolveFinish).toHaveBeenCalledTimes(5);
});
it("shouldTraceRequest disables tracing", async () => {
const tracer = new MockTracer();
const shouldTraceRequest = jest.fn(() => false);
const app = await createApp({ tracer, shouldTraceRequest });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
}
}`,
})
.expect(200);
expect(shouldTraceRequest).toHaveBeenCalledTimes(1);
expect(tracer.spans.length).toBe(0);
});
it("onRequestResolve", async () => {
const tracer = new MockTracer();
const onRequestResolve = jest.fn((span: any) => {
span.log({ onRequestResolve: "yes" });
});
const app = await createApp({ tracer, onRequestResolve });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
a {
one
two
}
b {
four
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
expect(onRequestResolve).toHaveBeenCalledTimes(1);
});
it("onRequestError", async () => {
const tracer = new MockTracer();
const onRequestError = jest.fn((span: any) => {
span.log({ onRequestError: "yes" });
});
const app = await createApp({ tracer, onRequestError });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.send({
query: `query {
e {
four
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
expect(onRequestError).toHaveBeenCalledTimes(1);
});
it("picks up external spans", async () => {
const tracer = new MockTracer();
const app = await createApp({ tracer });
await request(app)
.post("/graphql")
.set("Accept", "application/json")
.set("x-b3-traceid", "external")
.set("x-b3-spanid", "external")
.send({
query: `query {
a {
one
}
}`,
})
.expect(200);
const tree = buildSpanTree(tracer.spans);
expect(tree).toMatchSnapshot();
});
});
================================================
FILE: src/context.ts
================================================
import { Span } from "opentracing";
import { GraphQLResolveInfo, ResponsePath } from "graphql";
import { GraphQLRequestContext } from "apollo-server-plugin-base";
function isArrayPath(path: ResponsePath) {
return typeof path.key === "number";
}
export function buildPath(path: ResponsePath | undefined) {
let current = path;
const segments = [];
while (current != null) {
if (isArrayPath(current)) {
segments.push(`[${current.key}]`);
} else {
segments.push(current.key);
}
current = current.prev;
}
return segments.reverse().join(".");
}
export interface SpanContext extends Object {
_spans: Map<string, Span>;
getSpanByPath(info: ResponsePath): Span | undefined;
addSpan(span: Span, info: GraphQLResolveInfo): void;
// Passed in from the outside context
requestSpan?: Span;
}
function isSpanContext(obj: any): obj is SpanContext {
return (
obj.getSpanByPath instanceof Function && obj.addSpan instanceof Function
);
}
export function requestIsInstanceContextRequest<CTX extends SpanContext>(
request: GraphQLRequestContext<CTX | Object>
): request is GraphQLRequestContext<CTX> {
return isSpanContext(request.context);
}
// TODO: think about using symbols to hide these
export function addContextHelpers(obj: any): SpanContext {
if (isSpanContext(obj)) {
return obj;
}
obj._spans = new Map<string, Span>();
obj.getSpanByPath = function (path: ResponsePath): Span | undefined {
return this._spans.get(buildPath(isArrayPath(path) ? path.prev : path));
};
obj.addSpan = function (span: Span, info: GraphQLResolveInfo): void {
this._spans.set(buildPath(info.path), span);
};
return obj;
}
================================================
FILE: src/index.ts
================================================
import { Request } from "apollo-server-env";
import {
ApolloServerPlugin,
GraphQLRequestContext,
GraphQLRequestListener,
GraphQLRequestExecutionListener,
GraphQLFieldResolverParams,
GraphQLRequestContextDidEncounterErrors,
} from "apollo-server-plugin-base";
import { DocumentNode, GraphQLResolveInfo } from "graphql";
import { FORMAT_HTTP_HEADERS, Span, Tracer } from "opentracing";
import {
addContextHelpers,
SpanContext,
requestIsInstanceContextRequest,
} from "./context";
export { SpanContext, addContextHelpers };
const alwaysTrue = () => true;
const emptyFunction = () => {};
export interface InitOptions<TContext> {
server?: Tracer;
local?: Tracer;
onFieldResolveFinish?: (error: Error | null, result: any, span: Span) => void;
onFieldResolve?: (
source: any,
args: { [argName: string]: any },
context: SpanContext,
info: GraphQLResolveInfo
) => void;
shouldTraceRequest?: (info: GraphQLRequestContext<TContext>) => boolean;
shouldTraceFieldResolver?: (
source: any,
args: { [argName: string]: any },
context: SpanContext,
info: GraphQLResolveInfo
) => boolean;
onRequestResolve?: (
span: Span,
info: GraphQLRequestContext<TContext>
) => void;
createCustomSpanName?: (name: string, info: GraphQLResolveInfo) => string;
onRequestError?: (
rootSpan: Span,
info: GraphQLRequestContextDidEncounterErrors<TContext>
) => void;
}
export interface ExtendedGraphQLResolveInfo extends GraphQLResolveInfo {
span?: Span;
}
export interface RequestStart<TContext> {
request: Pick<Request, "url" | "method" | "headers">;
queryString?: string;
parsedQuery?: DocumentNode;
operationName?: string;
variables?: { [key: string]: any };
persistedQueryHit?: boolean;
persistedQueryRegister?: boolean;
context: TContext;
requestContext: GraphQLRequestContext<TContext>;
}
function getFieldName(info: GraphQLResolveInfo) {
if (
info.fieldNodes &&
info.fieldNodes.length > 0 &&
info.fieldNodes[0].alias
) {
return info.fieldNodes[0].alias.value;
}
return info.fieldName || "field";
}
function headersToOject(
headerIterator: Iterator<[string, string], any, undefined> | undefined
): Record<string, string> {
if (!headerIterator) {
return {};
}
const headers: Record<string, string> = {};
let header:
| IteratorYieldResult<[string, string]>
| IteratorReturnResult<any>
| undefined;
do {
header = headerIterator?.next();
if (header?.value) {
const [key, value] = header?.value;
headers[key] = value;
}
} while (!header?.done);
return headers;
}
export default function OpentracingPlugin<InstanceContext extends SpanContext>({
server,
local,
onFieldResolveFinish = emptyFunction,
onFieldResolve = emptyFunction,
shouldTraceRequest = alwaysTrue,
shouldTraceFieldResolver = alwaysTrue,
onRequestResolve = emptyFunction,
onRequestError = emptyFunction,
createCustomSpanName = (name, _) => name,
}: InitOptions<InstanceContext>): ApolloServerPlugin<InstanceContext> {
if (!server) {
throw new Error(
"ApolloOpentracing needs a server tracer, please provide it to the constructor. e.g. new ApolloOpentracing({ server: serverTracer, local: localTracer })"
);
}
if (!local) {
throw new Error(
"ApolloOpentracing needs a local tracer, please provide it to the constructor. e.g. new ApolloOpentracing({ server: serverTracer, local: localTracer })"
);
}
const serverTracer = server;
const localTracer = local;
let requestSpan: Span | null = null;
return {
async requestDidStart(
infos: GraphQLRequestContext<InstanceContext>
): Promise<GraphQLRequestListener<InstanceContext> | void> {
addContextHelpers(infos.context);
if (!requestIsInstanceContextRequest<InstanceContext>(infos)) {
console.warn(
"Context in request passed to apollo-opentracing#requestDidStart is not a SpanContext, aborting tracing"
);
return;
}
if (!shouldTraceRequest(infos)) {
return;
}
const headers = headersToOject(infos.request.http?.headers.entries());
const externalSpan = headers
? serverTracer.extract(FORMAT_HTTP_HEADERS, headers)
: undefined;
const rootSpan =
infos.context.requestSpan ||
serverTracer.startSpan(infos.operationName || "request", {
childOf: externalSpan ? externalSpan : undefined,
});
onRequestResolve(rootSpan, infos);
requestSpan = rootSpan;
return {
async willSendResponse() {
rootSpan.finish();
},
async executionDidStart(): Promise<
GraphQLRequestExecutionListener<InstanceContext>
> {
return {
willResolveField({
source,
args,
context,
info,
}: GraphQLFieldResolverParams<any, InstanceContext>) {
if (
// we don't trace the request
!requestSpan ||
// we should not trace this resolver
!shouldTraceFieldResolver(source, args, context, info) ||
// the previous resolver was not traced
(info.path &&
info.path.prev &&
!context.getSpanByPath(info.path.prev))
) {
return;
}
// idempotent method to add helpers to the first context available (which will be propagated by apollo)
addContextHelpers(context);
const name = createCustomSpanName(getFieldName(info), info);
const parentSpan =
info.path && info.path.prev
? context.getSpanByPath(info.path.prev)
: requestSpan;
const span = localTracer.startSpan(name, {
childOf: parentSpan || undefined,
});
context.addSpan(span, info);
// expose to field (although type does not contain it)
(info as any).span = span;
onFieldResolve(source, args, context, info);
return (error: Error | null, result: any) => {
onFieldResolveFinish(error, result, span);
span.finish();
};
},
};
},
didEncounterErrors: async (requestContext) => {
onRequestError(rootSpan, requestContext);
},
};
},
};
}
================================================
FILE: src/test/span-serializer.ts
================================================
import "jest";
import { MockSpanTree } from "./types";
const TAB = " ";
function prefix(depth: number) {
return TAB.repeat(depth) + `+-- `;
}
function logLine(log: any, index: number, depth: number) {
return `${TAB.repeat(depth + 1)}${index}. ${JSON.stringify(log).replace(
/\\"/g,
""
)}`;
}
function logs(span: MockSpanTree, depth: number) {
if (span.logs && span.logs.length > 0) {
return `${TAB.repeat(depth + 1)}logs:\n${span.logs
.map((log, index) => logLine(log, index + 1, depth))
.join("\n")}\n`;
}
return "";
}
function tags(span: MockSpanTree, depth: number) {
if (span.tags && span.tags.length > 0) {
return `${TAB.repeat(depth + 1)}tags:\n${span.tags
.map((tag, index) => logLine(tag, index + 1, depth))
.join("\n")}\n`;
}
return "";
}
function tag(span: MockSpanTree, depth: number) {
return `${span.name}:${span.id}\n${TAB.repeat(depth + 1)}finished: ${
span.finished
}\n${logs(span, depth)}\n${tags(span, depth)}`;
}
function buildSpan(span: MockSpanTree, depth = 0) {
let result = "";
result += tag(span, depth);
if (span.children) {
for (const child of span.children) {
result += `${prefix(depth)}${buildSpan(child, depth + 1)}`;
}
}
return result;
}
export default {
test: (val: any) => val.id && val.name && val.logs && val.finished != null,
print(val: any) {
return buildSpan(val as MockSpanTree);
},
};
================================================
FILE: src/test/types.ts
================================================
export interface MockSpan {
id: number;
parentId?: number;
name: string;
options?: any;
logs: any[];
tags: any[];
finished: boolean;
}
export interface MockSpanTree extends MockSpan {
children: MockSpanTree[];
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"removeComments": true,
"strict": true,
"skipLibCheck": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
"noUnusedLocals": true,
"forceConsistentCasingInFileNames": true,
"lib": ["es2017", "esnext.asynciterable"],
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["**/__tests__", "**/__mocks__"]
}
gitextract___y3lay0/ ├── .all-contributorsrc ├── .github/ │ ├── stale.yml │ └── workflows/ │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── commitlint.config.js ├── package.json ├── renovate.json ├── src/ │ ├── __tests__/ │ │ ├── __snapshots__/ │ │ │ └── integration-test.ts.snap │ │ └── integration-test.ts │ ├── context.ts │ ├── index.ts │ └── test/ │ ├── span-serializer.ts │ └── types.ts └── tsconfig.json
SYMBOL INDEX (28 symbols across 5 files)
FILE: src/__tests__/integration-test.ts
function buildEmptySpan (line 81) | function buildEmptySpan(
class MockTracer (line 98) | class MockTracer {
method constructor (line 100) | constructor() {
method extract (line 104) | extract(_idk: any, header: Record<string, string>) {
method startSpan (line 116) | startSpan(name: string, options: any) {
function createApp (line 150) | async function createApp<InstanceContext extends SpanContext>({
FILE: src/context.ts
function isArrayPath (line 4) | function isArrayPath(path: ResponsePath) {
function buildPath (line 8) | function buildPath(path: ResponsePath | undefined) {
type SpanContext (line 22) | interface SpanContext extends Object {
function isSpanContext (line 30) | function isSpanContext(obj: any): obj is SpanContext {
function requestIsInstanceContextRequest (line 36) | function requestIsInstanceContextRequest<CTX extends SpanContext>(
function addContextHelpers (line 43) | function addContextHelpers(obj: any): SpanContext {
FILE: src/index.ts
type InitOptions (line 23) | interface InitOptions<TContext> {
type ExtendedGraphQLResolveInfo (line 51) | interface ExtendedGraphQLResolveInfo extends GraphQLResolveInfo {
type RequestStart (line 54) | interface RequestStart<TContext> {
function getFieldName (line 66) | function getFieldName(info: GraphQLResolveInfo) {
function headersToOject (line 78) | function headersToOject(
function OpentracingPlugin (line 101) | function OpentracingPlugin<InstanceContext extends SpanContext>({
FILE: src/test/span-serializer.ts
constant TAB (line 4) | const TAB = " ";
function prefix (line 6) | function prefix(depth: number) {
function logLine (line 10) | function logLine(log: any, index: number, depth: number) {
function logs (line 17) | function logs(span: MockSpanTree, depth: number) {
function tags (line 27) | function tags(span: MockSpanTree, depth: number) {
function tag (line 36) | function tag(span: MockSpanTree, depth: number) {
function buildSpan (line 42) | function buildSpan(span: MockSpanTree, depth = 0) {
method print (line 58) | print(val: any) {
FILE: src/test/types.ts
type MockSpan (line 1) | interface MockSpan {
type MockSpanTree (line 11) | interface MockSpanTree extends MockSpan {
Condensed preview — 18 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (47K chars).
[
{
"path": ".all-contributorsrc",
"chars": 3764,
"preview": "{\n \"projectName\": \"apollo-opentracing\",\n \"projectOwner\": \"DanielMSchmidt\",\n \"repoType\": \"github\",\n \"repoHost\": \"http"
},
{
"path": ".github/stale.yml",
"chars": 669,
"preview": "# Number of days of inactivity before an issue becomes stale\ndaysUntilStale: 60\n# Number of days of inactivity before a "
},
{
"path": ".github/workflows/pr.yml",
"chars": 453,
"preview": "name: Node.js CI\n\non:\n push:\n branches: [master, main]\n pull_request:\n branches: [master, main]\n\njobs:\n build:\n"
},
{
"path": ".github/workflows/release.yml",
"chars": 564,
"preview": "name: Release\non:\n push:\n branches:\n - master\n - main\njobs:\n release:\n name: Release\n runs-on: ubun"
},
{
"path": ".gitignore",
"chars": 20,
"preview": "dist/\nnode_modules/\n"
},
{
"path": ".npmignore",
"chars": 64,
"preview": "*\n!src/**/*\n!dist/**/*\ndist/**/*.test.*\n!package.json\n!README.md"
},
{
"path": "LICENSE",
"chars": 1084,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2020 Daniel M. Schmidt\n\nPermission is hereby granted, free of charge, to any person"
},
{
"path": "README.md",
"chars": 11074,
"preview": "# Apollo Opentracing [](https://badge.fury.io/js/apollo-o"
},
{
"path": "commitlint.config.js",
"chars": 109,
"preview": "module.exports = {\n extends: [\"@commitlint/config-conventional\"],\n rules: {\n \"scope-case\": [0],\n },\n};\n"
},
{
"path": "package.json",
"chars": 1765,
"preview": "{\n \"name\": \"apollo-opentracing\",\n \"version\": \"0.0.0-development\",\n \"description\": \"Trace your GraphQL server with Ope"
},
{
"path": "renovate.json",
"chars": 148,
"preview": "{\n \"extends\": [\"config:base\", \":semanticCommits\"],\n \"automerge\": true,\n \"commitMessagePrefix\": \"fix:\",\n \"major\": {\n "
},
{
"path": "src/__tests__/__snapshots__/integration-test.ts.snap",
"chars": 2690,
"preview": "// Jest Snapshot v1, https://goo.gl/fbAQLP\n\nexports[`integration with apollo-server alias with fragment works 1`] = `\nre"
},
{
"path": "src/__tests__/integration-test.ts",
"chars": 10882,
"preview": "import * as express from \"express\";\nimport * as request from \"supertest\";\nimport { ApolloServer } from \"apollo-server-ex"
},
{
"path": "src/context.ts",
"chars": 1685,
"preview": "import { Span } from \"opentracing\";\nimport { GraphQLResolveInfo, ResponsePath } from \"graphql\";\nimport { GraphQLRequestC"
},
{
"path": "src/index.ts",
"chars": 6520,
"preview": "import { Request } from \"apollo-server-env\";\nimport {\n ApolloServerPlugin,\n GraphQLRequestContext,\n GraphQLRequestLis"
},
{
"path": "src/test/span-serializer.ts",
"chars": 1438,
"preview": "import \"jest\";\nimport { MockSpanTree } from \"./types\";\n\nconst TAB = \" \";\n\nfunction prefix(depth: number) {\n return TA"
},
{
"path": "src/test/types.ts",
"chars": 229,
"preview": "export interface MockSpan {\n id: number;\n parentId?: number;\n name: string;\n options?: any;\n logs: any[];\n tags: a"
},
{
"path": "tsconfig.json",
"chars": 635,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2016\",\n \"module\": \"commonjs\",\n \"moduleResolution\": \"node\",\n \"sourceMa"
}
]
About this extraction
This page contains the full source code of the DanielMSchmidt/apollo-opentracing GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 18 files (42.8 KB), approximately 12.1k tokens, and a symbol index with 28 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.