Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7373665
feat(pprof): initial commit
bevzzz Jun 4, 2024
6d5a52c
feat(pprof): calculate Range for each func/method
bevzzz Jun 4, 2024
0240819
fix(pprof): escape special characters for pprof search
bevzzz Jun 4, 2024
0a01fbd
wip(pprof): search pprof report and go binary
bevzzz Jun 5, 2024
9465d79
feat(pprof): get `pprof -top` for current package
bevzzz Jun 6, 2024
2692b18
fix(pprof/parser): espace all special characters in the string
bevzzz Jun 6, 2024
b36759b
feat(pprof): annotate Go files
bevzzz Jun 6, 2024
83e687f
test(pprof): enable all tests and simplify mocking
bevzzz Jun 7, 2024
33dbd71
fix(pprof): missing dot in default reportGlob
bevzzz Jun 7, 2024
194c62d
chore(pprof): return no annotations for a test file
bevzzz Jun 7, 2024
8f6d41d
chore(pprof): drop 'vscode' dependency, remove unused code
bevzzz Jun 7, 2024
89d9852
ci: appease biome
bevzzz Jun 7, 2024
0233847
feat(pprof): support standalone pprof
bevzzz Jun 11, 2024
75ccb13
feat(pprof): pass binary to pprof if found
bevzzz Jun 11, 2024
517c52b
feat(pprof): match reportGlob to a full file path
bevzzz Jun 11, 2024
9658227
fix(pprof): get units from `pprof -top` with >1 character
bevzzz Jun 12, 2024
478ca16
feat(pprof): pass detailed breakdown (pprof list) to "ai" field in an…
bevzzz Jun 12, 2024
063257a
refactor(pprof): store file's functions as a Record to minimize looping
bevzzz Jun 12, 2024
63526e3
chore(pprof): change annotation title
bevzzz Jun 12, 2024
7b4acab
feat(pprof): apply all provider settings
bevzzz Jun 12, 2024
2afee10
chore: appease biome
bevzzz Jun 13, 2024
3350351
build(pprof): bundle to esm format
bevzzz Jun 13, 2024
137bb1a
fix(pprof): avoid breaking search loop prematurely
bevzzz Jun 13, 2024
519daef
ci: fix formatting
bevzzz Jun 13, 2024
28f4f54
chore(pprof): write docs
bevzzz Jun 13, 2024
3bdc986
ci: appease biomi
bevzzz Dec 12, 2024
ca75157
ci: run biome format --fix
bevzzz Dec 12, 2024
f736c66
ci: fix more biome errors
bevzzz Dec 12, 2024
fa3b4df
ci: appease biome
bevzzz Dec 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

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

69 changes: 69 additions & 0 deletions provider/pprof/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# [pprof](https://github.com/google/pprof) context provider for OpenCtx

[OpenCtx](https://openctx.org) provider that annotates Go functions with their associated CPU time and memory allocations based on the CPU/memory profiles.

As profiling reports are usually not stored in a centralized remote location (like, e.g. docs or logs) and only exist on your machine, this provider only supports local VSCode client. It also does not provide annotations for test files.

When enabled, pprof provider will:

1. Search the workspace to find a profiling report and, optionally, a Go binary that produced it.
1. Get `pprof -top` nodes for the current package.
1. Create an annotation for each function/method in the current file denoting its resourse consumption.
1. Pass a detailed `pprof -list` breakdown to `annotation.item.ai` to be consumed by Cody.

## Usage

Add the following to your `settings.json`:

```json
"openctx.providers": {
// ...other providers...
"https://openctx.org/npm/@openctx/provider-pprof": true
},
```

Pprof provider has reasonable defaults, so no additional configuration in necessary if you follow the standard naming conventions for pprof reports and Go binaries, e.g. that a cpu profile report has `.pprof` extension.

Most of the time, however, you'll want to adjust the config to suit your preferences.

## Configuration

The default configuration looks like this:

```json
{
"reportGlob": "**/*.pprof",
"binaryGlob": undefined, // By default, looks for a binary whose name matches the name of its parent directory
"rootDirectoryMarkers": ["go.mod", ".git"],
"top": { // Options to control `pprof -top` output
"excludeInline": true, // Add `-noinlines`
"nodeCount": undefined, // Add `-nodecount=x`, not set by default
"sort": "cum" // Set `-cum` or `-flat`
}
}
```

## Limitations

`pprof` can collect stack traces for a number of [different profiles](https://pkg.go.dev/runtime/pprof#Profile):

```
goroutine - stack traces of all current goroutines
heap - a sampling of memory allocations of live objects
allocs - a sampling of all past memory allocations
threadcreate - stack traces that led to the creation of new OS threads
block - stack traces that led to blocking on synchronization primitives
mutex - stack traces of holders of contended mutexes
```

This provider only supports `heap` and CPU profile[^1].

## Development

- [Source code](https://sourcegraph.com/github.com/sourcegraph/openctx/-/tree/provider/pprof)
- [Docs](https://openctx.org/docs/providers/pprof)
- License: Apache 2.0

____

[^1]: The CPU profile is not available as a `runtime/pprof.Profile` and has a special API.
35 changes: 35 additions & 0 deletions provider/pprof/_log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* _log is a local debugging util that writes logs to a log file at $HOME/log/openctx/provider-pprof.log.
* I could not find a way to log things to VSCode Output, so I came up with this workaround.
*
* This file is not imported anywhere, so no directories will be created on your machine
* with `pprof` provider enabled.
*
* It's only a temporary fixture -- there's probably a better solution to this.
*/

import { appendFileSync, closeSync, mkdirSync, openSync, statSync } from 'node:fs'
import { join } from 'node:path'

const logDir = `${process.env.HOME}/log/openctx`
const logFile = join(logDir, 'provider-pprof.log')

try {
statSync(logDir)
} catch {
mkdirSync(logDir, { recursive: true })
}
closeSync(openSync(logFile, 'w'))

/**
* DEBUG writes logs to $HOME/log/openctx/provider-pprof.log
* To watch the logs run:
*
* ```
* tail -f $HOME/log/openctx/provider-pprof.log
* ```
*/
export default function DEBUG(message?: any, ...args: any[]): void {
const now = new Date(Date.now()).toUTCString()
appendFileSync(logFile, `[${now}] ${message}${args.join(' ')}` + '\n')
}
49 changes: 49 additions & 0 deletions provider/pprof/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { beforeEach } from 'node:test'
import type { MetaResult } from '@openctx/provider'
import { beforeAll, describe, expect, test, vi } from 'vitest'
import pprof from './index.js'
import { getPprof } from './pprof.js'

vi.mock('./pprof.js', async () => {
const actualPprof = await vi.importActual('./pprof.js')
return { ...actualPprof, getPprof: vi.fn() }
})
const getPprofMock = vi.mocked(getPprof)

describe('pprof', () => {
let actualPprof: typeof import('./pprof.js') | undefined
beforeAll(async () => {
actualPprof = await vi.importActual<typeof import('./pprof.js')>('./pprof.js')
})

beforeEach(async () => {
vi.clearAllMocks()

// All tests should use the actual implementation, we just want to spy on the calls.
// We also know that the original module is available, because it's imported in the beforeAll hook.
getPprofMock.mockImplementationOnce(actualPprof!.getPprof)
})

test('meta', () =>
expect(pprof.meta({}, {})).toStrictEqual<MetaResult>({
name: 'pprof',
annotations: {
selectors: [{ path: '**/*.go' }],
},
}))

test('annotations for a test file', () => {
const content = 'package pkg_test\nfunc DoStuff() {}\n'

expect(
pprof.annotations!(
{
uri: '/pkg/thing_test.go',
content: content,
},
{},
),
).toHaveLength(0)
expect(getPprofMock).not.toBeCalled()
})
})
135 changes: 135 additions & 0 deletions provider/pprof/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { dirname } from 'node:path'
import type {
Annotation,
AnnotationsParams,
AnnotationsResult,
Item,
MetaParams,
MetaResult,
Provider,
} from '@openctx/provider'
import { parseGolang } from './parser.js'
import { type Node, type TopOptions, findReportPath as findPprofSources, getPprof } from './pprof.js'

interface Settings {
/**
* Glob pattern to match the profile report.
*
* Note, that forward slashes _do not need_ to be escaped in the patterns provided in `settings.json`
*
* @default "**\/*.pprof"
* @example "**\/cmd\/*.pb.gz" (limit to asubdirectory)
*/
reportGlob?: string

/**
* Glob pattern to match the Go binary from which the report was generated.
*
* By default `binaryGlob` not set. The provider will try to locate it by searching
* for an executable file whose name matches that of its parent directory.
* This is what a binary produces by `go build .` would be conventionally named.
*/
binaryGlob?: string

/**
* The provider will not traverse the file tree past the directory containing `rootDirectoryMarkers`,
* when searching for the profile report and the binary.
*
* @default [".git", "go.mod"]
*/
rootDirectoryMarkers?: string[]

/**
* Options to control `pprof -top` output.
*
* @default top: { excludeInline: true, sort: 'cum' }
* @example top: { excludeInline: false, sort: 'flat', nodeCount: 10 }
*/
top?: Pick<TopOptions, 'excludeInline' | 'nodeCount' | 'sort'>
}

/**
* An [OpenCtx](https://openctx.org) provider that annotates every function declaration with
* the CPU time and memory allocations associated with it.
*
* Only Go files are supported.
*/
const pprof: Provider<Settings> = {
meta(params: MetaParams, settings: Settings): MetaResult {
return {
name: 'pprof',
annotations: {
selectors: [{ path: '**/*.go' }],
},
}
},

annotations(params: AnnotationsParams, settings: Settings): AnnotationsResult {
// Test files do not need pprof annotations.
if (params.uri.endsWith('_test.go')) {
return []
}

const pprof = getPprof()
if (pprof === null) {
// TODO: log that no command line tool was found. Ideally, do it once on init.
return []
}

const searchDir = dirname(params.uri).replace(/^file:\/{2}/, '')
const sources = findPprofSources(searchDir, {
reportGlob: settings.reportGlob || '**/*.pprof',
rootDirectoryMarkers: settings.rootDirectoryMarkers || ['.git', 'go.mod'],
binaryGlob: settings.binaryGlob,
// TODO: pass workspaceRoot once it's made available
// workspaceRoot: workspaceRoot,
})
if (!sources.report) {
return []
}
pprof.setSources(sources)

const content = parseGolang(params.content)
if (!content) {
return []
}

const top = pprof.top({ ...settings.top, package: content.package })
if (top === null) {
return []
}

const anns: Annotation[] = []
top.nodes.forEach((node: Node, i: number) => {
const func = content.funcs[node.function]
if (!func) {
return
}

let item: Item = {
title: `pprof ${top.type}: cum ${node.cum}${top.unit}, ${node.cumPerc}% (#${
i + 1
}, sort=${settings.top?.sort || 'cum'})`,
}

const list = pprof.list(node.function)
if (list) {
item = {
...item,
ai: {
content: "Output of 'pprof -list' command for this function:\n" + list.raw,
},
}
}

anns.push({
uri: params.uri,
range: func.range,
item: item,
})
})
return anns
},
}

export default pprof
26 changes: 26 additions & 0 deletions provider/pprof/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@openctx/provider-pprof",
"version": "0.0.13",
"description": "pprof (OpenCtx provider)",
"license": "Apache-2.0",
"homepage": "https://openctx.org/docs/providers/pprof",
"repository": {
"type": "git",
"url": "https://github.com/sourcegraph/openctx",
"directory": "provider/pprof"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist/index.js", "dist/index.d.ts"],
"sideEffects": false,
"scripts": {
"build": "tsc --build",
"bundle": "tsc --build && esbuild --log-level=error --bundle --format=esm --platform=node --outfile=dist/bundle.js index.ts",
"prepublishOnly": "tsc --build --clean && pnpm run --silent build",
"test": "vitest"
},
"dependencies": {
"@openctx/provider": "workspace:*"
}
}
50 changes: 50 additions & 0 deletions provider/pprof/parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'vitest'
import { type Contents, parseGolang } from './parser.js'

describe('file parsing', () => {
test('golang', () => {
const content = `package example

import "fmt"

func A(a string) {}
func b_func() {}
func A2() {
fmt.Print("hello pprof")
}

type Thing struct {}

func (t *Thing) doStuff(i int) {}
func (t Thing) String() string { return "thing" }
`

expect(parseGolang(content)).toStrictEqual<Contents>({
package: 'example',
funcs: {
'example.A': {
name: 'A',
range: { start: { line: 4, character: 5 }, end: { line: 4, character: 6 } },
},
'example.b_func': {
name: 'b_func',
range: { start: { line: 5, character: 5 }, end: { line: 5, character: 11 } },
},
'example.A2': {
name: 'A2',
range: { start: { line: 6, character: 5 }, end: { line: 6, character: 7 } },
},
'example.(*Thing).doStuff': {
name: 'doStuff',
range: { start: { line: 12, character: 16 }, end: { line: 12, character: 23 } },
receiver: '*Thing',
},
'example.(Thing).String': {
name: 'String',
range: { start: { line: 13, character: 15 }, end: { line: 13, character: 21 } },
receiver: 'Thing',
},
},
})
})
})
Loading
Loading