├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── docker-compose-base.yaml ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── BuildModeWebpackPlugin.ts │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .eslintcache ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 1-bug_report.md │ └── config.yml ├── dependabot.yml ├── issue_commands.json ├── pull_request_template.md ├── release.yml └── workflows │ ├── ci.yml │ ├── dependabot-reviewer.yml │ ├── detect-breaking-changes.yml │ ├── issue_commands.yml │ ├── publish.yml │ └── push.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── Magefile.go ├── README.md ├── cspell.config.json ├── docker-compose.yaml ├── e2e ├── frontend │ ├── configEditor.spec.ts │ ├── errors.ts │ └── variableEditor.spec.ts └── smoke │ └── configEditor.spec.ts ├── go.mod ├── go.sum ├── jest-setup.js ├── jest.config.js ├── package.json ├── pkg ├── errors │ └── errors.go ├── framer │ ├── converters.go │ └── framer.go ├── handlers │ ├── handlers.go │ └── handlers_test.go ├── main.go ├── mocker │ ├── doer.go │ └── testdata │ │ └── projects.json ├── plugin │ ├── plugin.go │ ├── plugin_test.go │ ├── router.go │ ├── router_test.go │ ├── settings.go │ └── settings_test.go ├── query │ ├── helpers.go │ └── model.go ├── sentry │ ├── client.go │ ├── client_test.go │ ├── constants.go │ ├── events.go │ ├── events_stats.go │ ├── issues.go │ ├── metrics.go │ ├── orgs.go │ ├── projects.go │ ├── sentry.go │ ├── stats_v2.go │ ├── tags.go │ └── team.go └── util │ ├── constants.go │ └── fakeclient.go ├── playwright.config.ts ├── provisioning └── datasources │ └── sentry.yaml ├── src ├── app │ ├── replace.spec.ts │ ├── replace.ts │ └── utils.ts ├── components │ ├── Error.tsx │ ├── config-editor │ │ ├── AdditionalSettings.tsx │ │ └── Divider.tsx │ ├── query-editor │ │ ├── EventsEditor.spec.tsx │ │ ├── EventsEditor.tsx │ │ ├── EventsStatsEditor.spec.tsx │ │ ├── EventsStatsEditor.tsx │ │ ├── IssuesEditor.spec.tsx │ │ ├── IssuesEditor.tsx │ │ ├── MetricsEditor.spec.tsx │ │ ├── MetricsEditor.tsx │ │ ├── QueryTypePicker.spec.tsx │ │ ├── QueryTypePicker.tsx │ │ ├── ScopePicker.spec.tsx │ │ ├── ScopePicker.tsx │ │ ├── StatsV2Editor.spec.tsx │ │ └── StatsV2Editor.tsx │ └── variable-query-editor │ │ ├── ProjectSelector.spec.tsx │ │ ├── ProjectSelector.tsx │ │ ├── TeamSelector.tsx │ │ ├── TypeSelector.spec.tsx │ │ └── TypeSelector.tsx ├── constants.ts ├── datasource.test.ts ├── datasource.ts ├── editors │ ├── SentryConfigEditor.test.tsx │ ├── SentryConfigEditor.tsx │ ├── SentryQueryEditor.test.tsx │ ├── SentryQueryEditor.tsx │ ├── SentryVariableEditor.test.tsx │ └── SentryVariableEditor.tsx ├── img │ ├── sentry-large.svg │ └── sentry-small.svg ├── module.ts ├── plugin.json ├── selectors.ts ├── setup │ └── tests.ts ├── styles.ts ├── styles │ └── editor.scss ├── tracking.test.ts ├── tracking.ts ├── types.ts └── utils │ ├── version.test.ts │ └── version.ts ├── tsconfig.json └── yarn.lock /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.18.5" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | }, 24 | { 25 | "files": ["./tests/**/*"], 26 | "rules": { 27 | "react-hooks/rules-of-hooks": "off", 28 | }, 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG anonymous_auth_enabled=true 7 | ARG development=false 8 | ARG TARGETARCH 9 | 10 | ARG GO_VERSION=1.21.6 11 | ARG GO_ARCH=${TARGETARCH:-amd64} 12 | 13 | ENV DEV "${development}" 14 | 15 | # Make it as simple as possible to access the grafana instance for development purposes 16 | # Do NOT enable these settings in a public facing / production grafana instance 17 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 18 | ENV GF_AUTH_ANONYMOUS_ENABLED "${anonymous_auth_enabled}" 19 | ENV GF_AUTH_BASIC_ENABLED "false" 20 | # Set development mode so plugins can be loaded without the need to sign 21 | ENV GF_DEFAULT_APP_MODE "development" 22 | 23 | 24 | LABEL maintainer="Grafana Labs " 25 | 26 | ENV GF_PATHS_HOME="/usr/share/grafana" 27 | WORKDIR $GF_PATHS_HOME 28 | 29 | USER root 30 | 31 | # Installing supervisor and inotify-tools 32 | RUN if [ "${development}" = "true" ]; then \ 33 | if grep -i -q alpine /etc/issue; then \ 34 | apk add supervisor inotify-tools git; \ 35 | elif grep -i -q ubuntu /etc/issue; then \ 36 | DEBIAN_FRONTEND=noninteractive && \ 37 | apt-get update && \ 38 | apt-get install -y supervisor inotify-tools git && \ 39 | rm -rf /var/lib/apt/lists/*; \ 40 | else \ 41 | echo 'ERROR: Unsupported base image' && /bin/false; \ 42 | fi \ 43 | fi 44 | 45 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 46 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 47 | 48 | 49 | # Installing Go 50 | RUN if [ "${development}" = "true" ]; then \ 51 | curl -O -L https://golang.org/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 52 | rm -rf /usr/local/go && \ 53 | tar -C /usr/local -xzf go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 54 | echo "export PATH=$PATH:/usr/local/go/bin:~/go/bin" >> ~/.bashrc && \ 55 | rm -f go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ 56 | fi 57 | 58 | # Installing delve for debugging 59 | RUN if [ "${development}" = "true" ]; then \ 60 | /usr/local/go/bin/go install github.com/go-delve/delve/cmd/dlv@latest; \ 61 | fi 62 | 63 | # Installing mage for plugin (re)building 64 | RUN if [ "${development}" = "true" ]; then \ 65 | git clone https://github.com/magefile/mage; \ 66 | cd mage; \ 67 | export PATH=$PATH:/usr/local/go/bin; \ 68 | go run bootstrap.go; \ 69 | fi 70 | 71 | # Inject livereload script into grafana index.html 72 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 73 | 74 | 75 | COPY entrypoint.sh /entrypoint.sh 76 | RUN chmod +x /entrypoint.sh 77 | ENTRYPOINT ["/entrypoint.sh"] 78 | -------------------------------------------------------------------------------- /.config/README.md: -------------------------------------------------------------------------------- 1 | # Default build configuration by Grafana 2 | 3 | **This is an auto-generated directory and is not intended to be changed! ⚠️** 4 | 5 | The `.config/` directory holds basic configuration for the different tools 6 | that are used to develop, test and build the project. In order to make it updates easier we ask you to 7 | not edit files in this folder to extend configuration. 8 | 9 | ## How to extend the basic configs? 10 | 11 | Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead 12 | to issues around working with the project. 13 | 14 | ### Extending the ESLint config 15 | 16 | Edit the `.eslintrc` file in the project root in order to extend the ESLint configuration. 17 | 18 | **Example:** 19 | 20 | ```json 21 | { 22 | "extends": "./.config/.eslintrc", 23 | "rules": { 24 | "react/prop-types": "off" 25 | } 26 | } 27 | ``` 28 | 29 | --- 30 | 31 | ### Extending the Prettier config 32 | 33 | Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration. 34 | 35 | **Example:** 36 | 37 | ```javascript 38 | module.exports = { 39 | // Prettier configuration provided by Grafana scaffolding 40 | ...require('./.config/.prettierrc.js'), 41 | 42 | semi: false, 43 | }; 44 | ``` 45 | 46 | --- 47 | 48 | ### Extending the Jest config 49 | 50 | There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`. 51 | 52 | **`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to 53 | set up the Jest DOM for the testing library and to apply some polyfills. ([link to Jest docs](https://jestjs.io/docs/configuration#setupfilesafterenv-array)) 54 | 55 | **`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration)) 56 | 57 | #### ESM errors with Jest 58 | 59 | A common issue with the current jest config involves importing an npm package that only offers an ESM build. These packages cause jest to error with `SyntaxError: Cannot use import statement outside a module`. To work around this, we provide a list of known packages to pass to the `[transformIgnorePatterns](https://jestjs.io/docs/configuration#transformignorepatterns-arraystring)` jest configuration property. If need be, this can be extended in the following way: 60 | 61 | ```javascript 62 | process.env.TZ = 'UTC'; 63 | const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils'); 64 | 65 | module.exports = { 66 | // Jest configuration provided by Grafana 67 | ...require('./.config/jest.config'), 68 | // Inform jest to only transform specific node_module packages. 69 | transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])], 70 | }; 71 | ``` 72 | 73 | --- 74 | 75 | ### Extending the TypeScript config 76 | 77 | Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration. 78 | 79 | **Example:** 80 | 81 | ```json 82 | { 83 | "extends": "./.config/tsconfig.json", 84 | "compilerOptions": { 85 | "preserveConstEnums": true 86 | } 87 | } 88 | ``` 89 | 90 | --- 91 | 92 | ### Extending the Webpack config 93 | 94 | Follow these steps to extend the basic Webpack configuration that lives under `.config/`: 95 | 96 | #### 1. Create a new Webpack configuration file 97 | 98 | Create a new config file that is going to extend the basic one provided by Grafana. 99 | It can live in the project root, e.g. `webpack.config.ts`. 100 | 101 | #### 2. Merge the basic config provided by Grafana and your custom setup 102 | 103 | We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this. 104 | 105 | ```typescript 106 | // webpack.config.ts 107 | import type { Configuration } from 'webpack'; 108 | import { merge } from 'webpack-merge'; 109 | import grafanaConfig from './.config/webpack/webpack.config'; 110 | 111 | const config = async (env): Promise => { 112 | const baseConfig = await grafanaConfig(env); 113 | 114 | return merge(baseConfig, { 115 | // Add custom config here... 116 | output: { 117 | asyncChunks: true, 118 | }, 119 | }); 120 | }; 121 | 122 | export default config; 123 | ``` 124 | 125 | #### 3. Update the `package.json` to use the new Webpack config 126 | 127 | We need to update the `scripts` in the `package.json` to use the extended Webpack configuration. 128 | 129 | **Update for `build`:** 130 | 131 | ```diff 132 | -"build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 133 | +"build": "webpack -c ./webpack.config.ts --env production", 134 | ``` 135 | 136 | **Update for `dev`:** 137 | 138 | ```diff 139 | -"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 140 | +"dev": "webpack -w -c ./webpack.config.ts --env development", 141 | ``` 142 | 143 | ### Configure grafana image to use when running docker 144 | 145 | By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. 146 | 147 | **Example:** 148 | 149 | ```yaml 150 | version: '3.7' 151 | 152 | services: 153 | grafana: 154 | extends: 155 | file: .config/docker-compose-base.yaml 156 | service: grafana 157 | build: 158 | args: 159 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 160 | grafana_image: ${GRAFANA_IMAGE:-grafana} 161 | ``` 162 | 163 | In this example, we assign the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will allow you to set the value while running the docker compose commands, which might be convenient in some scenarios. 164 | 165 | --- 166 | -------------------------------------------------------------------------------- /.config/docker-compose-base.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: root 4 | container_name: 'grafana-sentry-datasource' 5 | 6 | build: 7 | context: . 8 | args: 9 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 10 | grafana_version: ${GRAFANA_VERSION:-11.5.2} 11 | development: ${DEVELOPMENT:-false} 12 | anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true} 13 | ports: 14 | - 3000:3000/tcp 15 | - 2345:2345/tcp # delve 16 | security_opt: 17 | - 'apparmor:unconfined' 18 | - 'seccomp:unconfined' 19 | cap_add: 20 | - SYS_PTRACE 21 | volumes: 22 | - ../dist:/var/lib/grafana/plugins/grafana-sentry-datasource 23 | - ../provisioning:/etc/grafana/provisioning 24 | - ..:/root/grafana-sentry-datasource 25 | 26 | environment: 27 | NODE_ENV: development 28 | GF_LOG_FILTERS: plugin.grafana-sentry-datasource:debug 29 | GF_LOG_LEVEL: debug 30 | GF_DATAPROXY_LOGGING: 1 31 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-sentry-datasource 32 | -------------------------------------------------------------------------------- /.config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "${DEV}" = "false" ]; then 4 | echo "Starting test mode" 5 | exec /run.sh 6 | fi 7 | 8 | echo "Starting development mode" 9 | 10 | if grep -i -q alpine /etc/issue; then 11 | exec /usr/bin/supervisord -c /etc/supervisord.conf 12 | elif grep -i -q ubuntu /etc/issue; then 13 | exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 14 | else 15 | echo 'ERROR: Unsupported base image' 16 | exit 1 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | import { TextEncoder, TextDecoder } from 'util'; 10 | 11 | Object.assign(global, { TextDecoder, TextEncoder }); 12 | 13 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 14 | Object.defineProperty(global, 'matchMedia', { 15 | writable: true, 16 | value: (query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addListener: jest.fn(), // deprecated 21 | removeListener: jest.fn(), // deprecated 22 | addEventListener: jest.fn(), 23 | removeEventListener: jest.fn(), 24 | dispatchEvent: jest.fn(), 25 | }), 26 | }); 27 | 28 | HTMLCanvasElement.prototype.getContext = () => {}; 29 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=bash -c 'while [ ! -f /root/grafana-sentry-datasource/dist/gpx_sentry-datasource* ]; do sleep 1; done; /run.sh' 9 | stdout_logfile=/dev/fd/1 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | killasgroup=true 13 | stopasgroup=true 14 | autostart=true 15 | 16 | [program:delve] 17 | user=root 18 | command=/bin/bash -c 'pid=""; while [ -z "$pid" ]; do pid=$(pgrep -f gpx_sentry-datasource); done; /root/go/bin/dlv attach --api-version=2 --headless --continue --accept-multiclient --listen=:2345 $pid' 19 | stdout_logfile=/dev/fd/1 20 | stdout_logfile_maxbytes=0 21 | redirect_stderr=true 22 | killasgroup=false 23 | stopasgroup=false 24 | autostart=true 25 | autorestart=true 26 | 27 | [program:build-watcher] 28 | user=root 29 | command=/bin/bash -c 'while inotifywait -e modify,create,delete -r /var/lib/grafana/plugins/grafana-sentry-datasource; do echo "Change detected, restarting delve...";supervisorctl restart delve; done' 30 | stdout_logfile=/dev/fd/1 31 | stdout_logfile_maxbytes=0 32 | redirect_stderr=true 33 | killasgroup=true 34 | stopasgroup=true 35 | autostart=true 36 | 37 | [program:mage-watcher] 38 | user=root 39 | environment=PATH="/usr/local/go/bin:/root/go/bin:%(ENV_PATH)s" 40 | directory=/root/grafana-sentry-datasource 41 | command=/bin/bash -c 'git config --global --add safe.directory /root/grafana-sentry-datasource && mage -v watch' 42 | stdout_logfile=/dev/fd/1 43 | stdout_logfile_maxbytes=0 44 | redirect_stderr=true 45 | killasgroup=true 46 | stopasgroup=true 47 | autostart=true 48 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/BuildModeWebpackPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | 3 | const PLUGIN_NAME = 'BuildModeWebpack'; 4 | 5 | export class BuildModeWebpackPlugin { 6 | apply(compiler: webpack.Compiler) { 7 | compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { 8 | compilation.hooks.processAssets.tap( 9 | { 10 | name: PLUGIN_NAME, 11 | stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, 12 | }, 13 | async () => { 14 | const assets = compilation.getAssets(); 15 | for (const asset of assets) { 16 | if (asset.name.endsWith('plugin.json')) { 17 | const pluginJsonString = asset.source.source().toString(); 18 | const pluginJsonWithBuildMode = JSON.stringify( 19 | { 20 | ...JSON.parse(pluginJsonString), 21 | buildMode: compilation.options.mode, 22 | }, 23 | null, 24 | 4 25 | ); 26 | compilation.updateAsset(asset.name, new webpack.sources.RawSource(pluginJsonWithBuildMode)); 27 | } 28 | } 29 | } 30 | ); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function getCPConfigVersion() { 33 | const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); 34 | return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; 35 | } 36 | 37 | export function hasReadme() { 38 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 39 | } 40 | 41 | // Support bundling nested plugins by finding all plugin.json files in src directory 42 | // then checking for a sibling module.[jt]sx? file. 43 | export async function getEntries(): Promise> { 44 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 45 | 46 | const plugins = await Promise.all( 47 | pluginsJson.map((pluginJson) => { 48 | const folder = path.dirname(pluginJson); 49 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 50 | }) 51 | ); 52 | 53 | return plugins.reduce((result, modules) => { 54 | return modules.reduce((result, module) => { 55 | const pluginPath = path.dirname(module); 56 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 57 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 58 | 59 | result[entryName] = module; 60 | return result; 61 | }, result); 62 | }, {}); 63 | } 64 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | * @grafana/partner-datasources 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug you found when using this plugin 4 | labels: ['datasource/Sentry', 'type/bug'] 5 | --- 6 | 7 | 15 | 16 | **What happened**: 17 | 18 | **What you expected to happen**: 19 | 20 | **How to reproduce it (as minimally and precisely as possible)**: 21 | 22 | 30 | 31 | **Screenshots** 32 | 33 | 36 | 37 | **Anything else we need to know?**: 38 | 39 | **Environment**: 40 | 41 | - Grafana version: 42 | - Plugin version: 43 | - OS Grafana is installed on: 44 | - User OS & Browser: 45 | - Others: 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request 4 | url: https://github.com/grafana/sentry-datasource/discussions/new 5 | about: Discuss ideas for new features or changes 6 | - name: Questions & Help 7 | url: https://community.grafana.com 8 | about: Please ask and answer questions here 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | # Maintain dependencies for GitHub Actions 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "npm" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | groups: 17 | dependencies: 18 | patterns: 19 | - "@grafana/*" 20 | dev-dependencies: 21 | patterns: 22 | - "@grafana/e2e*" 23 | 24 | # Ignore most dependencies that are maintained via the create-plugin tool 25 | # See https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/common/package.json 26 | ignore: 27 | - dependency-name: "@playwright/test" 28 | - dependency-name: "@babel/core" 29 | - dependency-name: "@emotion/css" 30 | - dependency-name: "@grafana/eslint-config" 31 | - dependency-name: "@grafana/tsconfig" 32 | - dependency-name: "@swc/core" 33 | - dependency-name: "@swc/helpers" 34 | - dependency-name: "@swc/jest" 35 | - dependency-name: "@testing-library/jest-dom" 36 | - dependency-name: "@testing-library/react" 37 | - dependency-name: "@types/jest" 38 | - dependency-name: "@types/lodash" 39 | - dependency-name: "@types/node" 40 | - dependency-name: "@types/react-router-dom" 41 | - dependency-name: react 42 | - dependency-name: react-dom 43 | - dependency-name: react-router-dom 44 | - dependency-name: rxjs 45 | - dependency-name: tslib 46 | - dependency-name: copy-webpack-plugin 47 | - dependency-name: css-loader 48 | - dependency-name: eslint-webpack-plugin 49 | - dependency-name: fork-ts-checker-webpack-plugin 50 | - dependency-name: glob 51 | - dependency-name: identity-obj-proxy 52 | - dependency-name: jest 53 | - dependency-name: jest-environment-jsdom 54 | - dependency-name: prettier 55 | - dependency-name: replace-in-file-webpack-plugin 56 | - dependency-name: sass 57 | - dependency-name: sass-loader 58 | - dependency-name: style-loader 59 | - dependency-name: swc-loader 60 | - dependency-name: ts-node 61 | - dependency-name: tsconfig-paths 62 | - dependency-name: typescript 63 | - dependency-name: webpack 64 | - dependency-name: webpack-cli 65 | - dependency-name: webpack-livereload-plug 66 | -------------------------------------------------------------------------------- /.github/issue_commands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "label", 4 | "name": "datasource/Sentry", 5 | "action": "addToProject", 6 | "addToProject": { 7 | "url": "https://github.com/orgs/grafana/projects/190" 8 | } 9 | }, 10 | { 11 | "type": "label", 12 | "name": "datasource/Sentry", 13 | "action": "removeFromProject", 14 | "addToProject": { 15 | "url": "https://github.com/orgs/grafana/projects/190" 16 | } 17 | }, 18 | { 19 | "type": "label", 20 | "name": "type/docs", 21 | "action": "addToProject", 22 | "addToProject": { 23 | "url": "https://github.com/orgs/grafana/projects/69" 24 | } 25 | }, 26 | { 27 | "type": "label", 28 | "name": "type/docs", 29 | "action": "removeFromProject", 30 | "addToProject": { 31 | "url": "https://github.com/orgs/grafana/projects/69" 32 | } 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Copy the following lines for the CHANGELOG 4 | labels: 5 | - changelog 6 | - title: Hidden 7 | exclude: 8 | labels: 9 | - '*' 10 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-reviewer.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot reviewer 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | call-workflow-passing-data: 11 | uses: grafana/security-github-actions/.github/workflows/dependabot-automerge.yaml@main 12 | with: 13 | repository-merge-method: squash 14 | # Add this to define production packages that dependabot can auto-update if the bump is minor 15 | packages-minor-autoupdate: '["@grafana/data","@grafana/ui","@grafana/runtime","@grafana/e2e-selectors"]' 16 | secrets: inherit 17 | -------------------------------------------------------------------------------- /.github/workflows/detect-breaking-changes.yml: -------------------------------------------------------------------------------- 1 | name: Compatibility check 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | compatibilitycheck: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | persist-credentials: false 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version-file: '.nvmrc' 14 | - name: Install dependencies 15 | run: yarn install 16 | - name: Build plugin 17 | run: yarn build 18 | - name: Compatibility check 19 | uses: grafana/plugin-actions/is-compatible@f567fc6454619e6c8dbc2f91692197457c10a02b 20 | with: 21 | module: './src/module.ts' 22 | comment-pr: 'yes' 23 | skip-comment-if-compatible: 'yes' 24 | fail-if-incompatible: 'no' 25 | targets: '@grafana/data,@grafana/ui,@grafana/runtime,@grafana/e2e-selectors' 26 | -------------------------------------------------------------------------------- /.github/workflows/issue_commands.yml: -------------------------------------------------------------------------------- 1 | name: Run commands when issues are labeled 2 | on: 3 | issues: 4 | types: [labeled] 5 | jobs: 6 | main: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Actions 10 | uses: actions/checkout@v4 11 | with: 12 | repository: 'grafana/grafana-github-actions' 13 | path: ./actions 14 | ref: main 15 | persist-credentials: false 16 | - name: Install Actions 17 | run: npm install --production --prefix ./actions 18 | - name: Run Commands 19 | uses: ./actions/commands 20 | with: 21 | token: ${{secrets.ISSUE_COMMANDS_TOKEN}} 22 | configPath: issue_commands 23 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Plugins - CD 2 | run-name: Deploy ${{ inputs.branch }} to ${{ inputs.environment }} by @${{ github.actor }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branch: 8 | description: Branch to publish from. Can be used to deploy PRs to dev 9 | default: main 10 | environment: 11 | description: Environment to publish to 12 | required: true 13 | type: choice 14 | options: 15 | - "dev" 16 | - "ops" 17 | - "prod" 18 | docs-only: 19 | description: Only publish docs, do not publish the plugin 20 | default: false 21 | type: boolean 22 | 23 | permissions: {} 24 | 25 | jobs: 26 | cd: 27 | name: CD 28 | uses: grafana/plugin-ci-workflows/.github/workflows/cd.yml@main # zizmor: ignore[unpinned-uses] 29 | permissions: 30 | contents: write 31 | id-token: write 32 | attestations: write 33 | with: 34 | branch: ${{ github.event.inputs.branch }} 35 | environment: ${{ github.event.inputs.environment }} 36 | docs-only: ${{ fromJSON(github.event.inputs.docs-only) }} 37 | scopes: universal 38 | go-version: '1.24' 39 | golangci-lint-version: '2.1.6' 40 | run-playwright: false 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Plugins - CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | ci: 13 | name: CI 14 | uses: grafana/plugin-ci-workflows/.github/workflows/ci.yml@main # zizmor: ignore[unpinned-uses] 15 | permissions: 16 | contents: read 17 | id-token: write 18 | with: 19 | plugin-version-suffix: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} 20 | go-version: '1.24' 21 | golangci-lint-version: '2.1.6' 22 | run-playwright: false 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | coverage/** 5 | cypress/report.json 6 | cypress/screenshots/actual 7 | cypress/videos/ 8 | dev/ 9 | /test-results/ 10 | /playwright-report/ 11 | /blob-report/ 12 | /playwright/.cache/ 13 | /playwright/.auth/ -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require("./.config/.prettierrc.js") 4 | }; -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | // temporary debug configuration, we'll update this to use the usual plugin-sdk method soon 5 | // ! RUN `mage -v build:debug` AND RESTART GRAFANA BEFORE STARTING THE DEBUGGER ! 6 | { 7 | "name": "Debug via attach", 8 | "type": "go", 9 | "request": "attach", 10 | "mode": "local", 11 | "debugAdapter": "dlv-dap" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## 2.1.1 (2025-05-29) 5 | 6 | - Dependency updates 7 | 8 | ## 2.1.0 (2025-04-17) 9 | 10 | - Support sorting direction in the events query builder ([#478](https://github.com/grafana/sentry-datasource/pull/478)) 11 | - Support tags auto-completion in the events query builder ([#477](https://github.com/grafana/sentry-datasource/pull/477)) 12 | - Dependency updates 13 | 14 | ## 2.0.0 (2025-04-02) 15 | 16 | - Allow choosing fields to fetch for sentry events ([#400](https://github.com/grafana/sentry-datasource/pull/400)) 17 | - Create plugin update, bump minimum supported Grafana version to 10.4.0 ([#452](https://github.com/grafana/sentry-datasource/pull/452)) 18 | - Update config to support fork tests ([#438](https://github.com/grafana/sentry-datasource/pull/438)) 19 | - Dependency updates 20 | 21 | ## 1.9.0 (2025-02-19) 22 | 23 | - Make substatus data available ([#393](https://github.com/grafana/sentry-datasource/pull/393)) 24 | - Allow sorting Events by a custom field ([#403](https://github.com/grafana/sentry-datasource/pull/403)) 25 | - Migrate from `@grafana/experimental` to `@grafana/plugin-ui` ([#415](https://github.com/grafana/sentry-datasource/pull/415)) 26 | - Dependency updates 27 | 28 | ## 1.8.5 (2024-11-28) 29 | 30 | - Dependency updates 31 | 32 | ## 1.8.4 (2024-11-07) 33 | 34 | - Dependency updates 35 | 36 | ## 1.8.3 (2024-09-26) 37 | 38 | - Dependency updates 39 | 40 | ## 1.8.2 (2024-08-28) 41 | 42 | - Add `errorsource` support ([#313](https://github.com/grafana/sentry-datasource/pull/313)) 43 | - Dependency updates 44 | 45 | ## 1.8.1 (2024-08-09) 46 | 47 | - Fix timerange bug with Event Stats query ([#279](https://github.com/grafana/sentry-datasource/pull/279)) 48 | - Fix Event data link in ID field (with thanks @Pexers) ([#286](https://github.com/grafana/sentry-datasource/pull/286)) 49 | - Dependency updates 50 | 51 | ## 1.8.0 (2024-05-30) 52 | 53 | - Add support for Metrics queries (with thanks again to @oblador) ([#254](https://github.com/grafana/sentry-datasource/pull/254)) 54 | - Dependency updates 55 | 56 | ## 1.7.0 (2024-05-07) 57 | 58 | - Add support for Events Stats queries (with thanks to @oblador) ([#225](https://github.com/grafana/sentry-datasource/pull/225)) 59 | - Minor update to tooltip text when entering an organization slug 60 | - Dependency updates 61 | 62 | ## 1.6.0 (2024-03-22) 63 | 64 | - Dependency updates 65 | 66 | ## 1.5.0 (2023-12-11) 67 | 68 | - Add interval to statsV2 69 | - Bump github.com/grafana/grafana-plugin-sdk-go from 0.179.0 to 0.196.0 70 | - Add getRequiredFields function 71 | - Other dependency updates 72 | 73 | ## 1.4.0 (2023-11-15) 74 | 75 | - Security and dependencies updates 76 | - Support for Sentry Events ([#100](https://github.com/grafana/sentry-datasource/pull/100)) 77 | - Support for pagination ([#77](https://github.com/grafana/sentry-datasource/pull/77)) 78 | - Updates to Editors 79 | 80 | ## 1.3.0 (2023-08-29) 81 | 82 | - Add support for the secure socks proxy 83 | - Updates sdk version to 0.171.0 84 | - Upgrade moment 85 | 86 | ## 1.2.1 (2023-05-03) 87 | 88 | - Build with latest Go version 1.20.4 89 | 90 | ## 1.2.0 (2023-04-20) 91 | 92 | - Update backend dependencies 93 | 94 | ## 1.1.0 (2022-12-19) 95 | 96 | - Teams template variable support 97 | - Filter projects by team in variables 98 | - Update go to the latest version (1.19.4) 99 | - Update grafana backend dependencies 100 | 101 | ## 1.0.1 (2022-02-07) 102 | 103 | - Docs update to plugin requirements. 104 | 105 | ## 1.0.0 (2021-11-08) 106 | 107 | - Initial release. 108 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //+build mage 2 | 3 | package main 4 | 5 | import ( 6 | // mage:import 7 | build "github.com/grafana/grafana-plugin-sdk-go/build" 8 | ) 9 | 10 | // Default configures the default target. 11 | var Default = build.BuildAll 12 | -------------------------------------------------------------------------------- /cspell.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "en", 3 | "ignorePaths": [ 4 | "node_modules", 5 | "dist", 6 | "coverage", 7 | "cypress", 8 | "provisioning", 9 | "pkg/mocker/testdata", 10 | "pkg/**/*_test.go", 11 | "mage_output_file.go", 12 | "src/**/*.{test,spec}.{ts,tsx}", 13 | "CHANGELOG.md", 14 | "e2e", 15 | "playwright-report", 16 | "package.json" 17 | ], 18 | "words": [ 19 | "DataSource", 20 | "DataSources", 21 | "endregion", 22 | "framestruct", 23 | "Grafana", 24 | "httpadapter", 25 | "httpclient", 26 | "instancemgmt", 27 | "jackspeak", 28 | "errorsource", 29 | "mhdw", 30 | "nolint", 31 | "oblador", 32 | "peterhellberg", 33 | "proxying", 34 | "Substatus", 35 | "substatus", 36 | "testdata", 37 | "testid", 38 | "typecheck", 39 | "Unmarshaling" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | container_name: 'grafana-sentry-datasource' 4 | build: 5 | context: ./.config 6 | args: 7 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 8 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 9 | ports: 10 | - 3000:3000/tcp 11 | volumes: 12 | - ./dist:/var/lib/grafana/plugins/grafana-sentry-datasource 13 | - ./provisioning:/etc/grafana/provisioning 14 | environment: 15 | ORG_SLUG: ${ORG_SLUG} 16 | AUTH_TOKEN: ${AUTH_TOKEN} 17 | -------------------------------------------------------------------------------- /e2e/frontend/configEditor.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@grafana/plugin-e2e'; 2 | import { formatExpectError } from './errors'; 3 | import { Components } from '../../src/selectors'; 4 | import { SentryConfig, SentrySecureConfig } from '../../src/types'; 5 | 6 | test.describe('Test config editor', () => { 7 | test('empty configuration should throw valid error', async ({ createDataSourceConfigPage, page }) => { 8 | const configPage = await createDataSourceConfigPage({ type: 'grafana-sentry-datasource' }); 9 | 10 | await expect(configPage.saveAndTest()).not.toBeOK(); 11 | await expect(page.getByTestId('data-testid Alert error')).toHaveText('invalid or empty organization slug'); 12 | }); 13 | 14 | test('empty auth token should throw valid error', async ({ createDataSourceConfigPage, page }) => { 15 | const configPage = await createDataSourceConfigPage({ type: 'grafana-sentry-datasource' }); 16 | await page.getByPlaceholder(Components.ConfigEditor.SentrySettings.OrgSlug.placeholder).fill('ORG_SLUG'); 17 | 18 | await expect(configPage.saveAndTest()).not.toBeOK(); 19 | await expect(page.getByTestId('data-testid Alert error')).toHaveText('empty or invalid auth token found'); 20 | }); 21 | 22 | test('invalid auth token should throw valid error', async ({ createDataSourceConfigPage, page }) => { 23 | const configPage = await createDataSourceConfigPage({ 24 | type: 'grafana-sentry-datasource', 25 | name: 'test-sentry-datasource', 26 | }); 27 | await page.getByPlaceholder(Components.ConfigEditor.SentrySettings.OrgSlug.placeholder).fill('ORG_SLUG'); 28 | await page 29 | .getByPlaceholder(Components.ConfigEditor.SentrySettings.AuthToken.placeholder) 30 | .fill('invalid-auth-token'); 31 | 32 | await expect(configPage.saveAndTest()).not.toBeOK(); 33 | }); 34 | 35 | test('valid configuration should return valid health check', async ({ 36 | readProvisionedDataSource, 37 | gotoDataSourceConfigPage, 38 | }) => { 39 | const datasource = await readProvisionedDataSource({ 40 | fileName: 'sentry.yaml', 41 | name: 'Sentry', 42 | }); 43 | const configPage = await gotoDataSourceConfigPage(datasource.uid); 44 | configPage.mockHealthCheckResponse({ status: 200 }); 45 | 46 | await expect( 47 | configPage.saveAndTest(), 48 | formatExpectError('Expected data source config to be successfully saved') 49 | ).toBeOK(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /e2e/frontend/errors.ts: -------------------------------------------------------------------------------- 1 | export const formatExpectError = (message: string) => { 2 | return `Error while verifying @grafana/plugin-e2e scenarios: ${message}. 3 | See https://github.com/grafana/grafana/blob/main/plugin-e2e/plugin-e2e-api-tests/README.md for more information.`; 4 | }; 5 | 6 | -------------------------------------------------------------------------------- /e2e/frontend/variableEditor.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@grafana/plugin-e2e'; 2 | import { formatExpectError } from './errors'; 3 | import { Components } from '../../src/selectors'; 4 | import { SentryConfig, SentrySecureConfig } from '../../src/types'; 5 | 6 | test.describe('Sentry variables', () => { 7 | test('add and edit variables', async ({ variableEditPage, page, readProvisionedDataSource }) => { 8 | const datasource = await readProvisionedDataSource({ 9 | fileName: 'sentry.yaml', 10 | }); 11 | await variableEditPage.datasource.set(datasource.name); 12 | await page.keyboard.press('Enter'); 13 | await page.getByTestId(Components.VariablesEditor.QueryType.selectorTestId).getByRole('combobox').fill(`Projects`); 14 | await page.keyboard.press('Enter'); 15 | await variableEditPage.runQuery(); 16 | await expect( 17 | variableEditPage, 18 | formatExpectError('Expected variable edit page to display certain label names after query execution') 19 | ).toDisplayPreviews([]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /e2e/smoke/configEditor.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@grafana/plugin-e2e'; 2 | import { Components } from '../../src/selectors'; 3 | 4 | test.describe('Create Sentry datasource - smoke', () => { 5 | test('renders the config editor', async ({ createDataSourceConfigPage, page }) => { 6 | const configPage = await createDataSourceConfigPage({ 7 | type: 'grafana-sentry-datasource', 8 | }); 9 | 10 | await expect(page.getByPlaceholder(Components.ConfigEditor.SentrySettings.URL.placeholder)).toBeVisible(); 11 | await expect(page.getByPlaceholder(Components.ConfigEditor.SentrySettings.OrgSlug.placeholder)).toBeVisible(); 12 | await expect(page.getByPlaceholder(Components.ConfigEditor.SentrySettings.AuthToken.placeholder)).toBeVisible(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/sentry-datasource 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/gorilla/mux v1.8.1 9 | github.com/grafana/grafana-plugin-sdk-go v0.277.1 10 | github.com/peterhellberg/link v1.2.0 11 | github.com/stretchr/testify v1.10.0 12 | ) 13 | 14 | require ( 15 | github.com/BurntSushi/toml v1.4.0 // indirect 16 | github.com/apache/arrow-go/v18 v18.2.0 // indirect 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 19 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 20 | github.com/cheekybits/genny v1.0.0 // indirect 21 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect 22 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/elazarl/goproxy v1.7.2 // indirect 25 | github.com/fatih/color v1.15.0 // indirect 26 | github.com/getkin/kin-openapi v0.132.0 // indirect 27 | github.com/go-logr/logr v1.4.2 // indirect 28 | github.com/go-logr/stdr v1.2.2 // indirect 29 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 30 | github.com/go-openapi/swag v0.23.0 // indirect 31 | github.com/goccy/go-json v0.10.5 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang/protobuf v1.5.4 // indirect 34 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 35 | github.com/google/go-cmp v0.7.0 // indirect 36 | github.com/google/uuid v1.6.0 // indirect 37 | github.com/grafana/otel-profiling-go v0.5.1 // indirect 38 | github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect 39 | github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect 40 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect 41 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect 42 | github.com/hashicorp/go-hclog v1.6.3 // indirect 43 | github.com/hashicorp/go-plugin v1.6.3 // indirect 44 | github.com/hashicorp/yamux v0.1.1 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/json-iterator/go v1.1.12 // indirect 47 | github.com/klauspost/compress v1.18.0 // indirect 48 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 49 | github.com/magefile/mage v1.15.0 // indirect 50 | github.com/mailru/easyjson v0.7.7 // indirect 51 | github.com/mattetti/filebuffer v1.0.1 // indirect 52 | github.com/mattn/go-colorable v0.1.13 // indirect 53 | github.com/mattn/go-isatty v0.0.20 // indirect 54 | github.com/mattn/go-runewidth v0.0.16 // indirect 55 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 56 | github.com/modern-go/reflect2 v1.0.2 // indirect 57 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 58 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 59 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 60 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 61 | github.com/oklog/run v1.1.0 // indirect 62 | github.com/olekukonko/tablewriter v0.0.5 // indirect 63 | github.com/perimeterx/marshmallow v1.1.5 // indirect 64 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 65 | github.com/pmezard/go-difflib v1.0.0 // indirect 66 | github.com/prometheus/client_golang v1.20.5 // indirect 67 | github.com/prometheus/client_model v0.6.1 // indirect 68 | github.com/prometheus/common v0.63.0 // indirect 69 | github.com/prometheus/procfs v0.15.1 // indirect 70 | github.com/rivo/uniseg v0.4.4 // indirect 71 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 72 | github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect 73 | github.com/unknwon/com v1.0.1 // indirect 74 | github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect 75 | github.com/urfave/cli v1.22.16 // indirect 76 | github.com/zeebo/xxh3 v1.0.2 // indirect 77 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 78 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 79 | go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect 80 | go.opentelemetry.io/contrib/propagators/jaeger v1.35.0 // indirect 81 | go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 // indirect 82 | go.opentelemetry.io/otel v1.35.0 // indirect 83 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 84 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 85 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 86 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 87 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 88 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 89 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 90 | golang.org/x/mod v0.23.0 // indirect 91 | golang.org/x/net v0.39.0 // indirect 92 | golang.org/x/sync v0.13.0 // indirect 93 | golang.org/x/sys v0.32.0 // indirect 94 | golang.org/x/text v0.24.0 // indirect 95 | golang.org/x/tools v0.30.0 // indirect 96 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 97 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 98 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 99 | google.golang.org/grpc v1.71.1 // indirect 100 | google.golang.org/protobuf v1.36.6 // indirect 101 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 102 | gopkg.in/yaml.v3 v3.0.1 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | import { TextEncoder, TextDecoder } from 'util'; 4 | 5 | global.TextEncoder = TextEncoder; 6 | global.TextDecoder = TextDecoder; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // force timezone to UTC to allow tests to work regardless of local timezone 2 | // generally used by snapshots, but can affect specific tests 3 | process.env.TZ = 'UTC'; 4 | 5 | module.exports = { 6 | // Jest configuration provided by Grafana scaffolding 7 | ...require('./.config/jest.config'), 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-sentry-datasource", 3 | "version": "2.1.1", 4 | "description": "Grafana data source plugin for Sentry", 5 | "main": "dist/module.js", 6 | "author": "Grafana Labs", 7 | "license": "SEE LICENSE IN LICENSE", 8 | "private": true, 9 | "scripts": { 10 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 11 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 12 | "dev_mock": "docker-compose -f docker-compose-mock.yml up", 13 | "e2e:playwright": "yarn playwright test", 14 | "e2e:playwright:ui": "yarn playwright test --ui", 15 | "e2e:playwright:report": "yarn playwright show-report", 16 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 17 | "lint:fix": "yarn run lint --fix && prettier --write --list-different .", 18 | "server": "docker compose up --build", 19 | "sign": "npx --yes @grafana/sign-plugin@latest", 20 | "spellcheck": "cspell -c cspell.config.json \"**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}\"", 21 | "start": "yarn watch", 22 | "test": "jest --watch --onlyChanged", 23 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 24 | "typecheck": "tsc --noEmit", 25 | "e2e": "playwright test" 26 | }, 27 | "dependencies": { 28 | "@emotion/css": "11.11.2", 29 | "@grafana/data": "12.0.1", 30 | "@grafana/plugin-ui": "^0.10.6", 31 | "@grafana/runtime": "12.0.1", 32 | "@grafana/schema": "12.0.1", 33 | "@grafana/ui": "12.0.1", 34 | "lodash": "^4.17.21", 35 | "react": "18.2.0", 36 | "react-dom": "18.2.0", 37 | "rxjs": "7.8.1", 38 | "tslib": "2.6.2" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.27.3", 42 | "@grafana/e2e-selectors": "12.0.1", 43 | "@grafana/eslint-config": "^8.0.0", 44 | "@grafana/plugin-e2e": "^2.0.2", 45 | "@grafana/tsconfig": "^2.0.0", 46 | "@playwright/test": "1.50.1", 47 | "@swc/core": "^1.3.90", 48 | "@swc/helpers": "^0.5.7", 49 | "@swc/jest": "^0.2.36", 50 | "@testing-library/dom": "^10.4.0", 51 | "@testing-library/jest-dom": "6.4.2", 52 | "@testing-library/react": "14.2.2", 53 | "@testing-library/user-event": "^14.6.1", 54 | "@types/glob": "^8.0.0", 55 | "@types/jest": "^29.5.12", 56 | "@types/lodash": "^4.17.0", 57 | "@types/node": "^20.8.7", 58 | "@types/react-router-dom": "^5.2.0", 59 | "copy-webpack-plugin": "^12.0.2", 60 | "cspell": "^9.0.2", 61 | "css-loader": "^6.10.0", 62 | "eslint-plugin-deprecation": "^3.0.0", 63 | "eslint-webpack-plugin": "^4.1.0", 64 | "fork-ts-checker-webpack-plugin": "^9.0.2", 65 | "glob": "^10.3.10", 66 | "identity-obj-proxy": "3.0.0", 67 | "jest": "^29.7.0", 68 | "jest-environment-jsdom": "^29.7.0", 69 | "prettier": "^3.2.5", 70 | "replace-in-file-webpack-plugin": "^1.0.6", 71 | "sass": "1.72.0", 72 | "sass-loader": "14.1.1", 73 | "style-loader": "3.3.4", 74 | "swc-loader": "^0.2.6", 75 | "ts-node": "^10.9.2", 76 | "tsconfig-paths": "^4.2.0", 77 | "typescript": "5.5.4", 78 | "webpack": "^5.94.0", 79 | "webpack-cli": "^5.1.4", 80 | "webpack-livereload-plugin": "^3.0.2", 81 | "@stylistic/eslint-plugin-ts": "^3.1.0", 82 | "@types/testing-library__jest-dom": "5.14.8", 83 | "@typescript-eslint/eslint-plugin": "^7.0.0", 84 | "@typescript-eslint/parser": "^6.18.0", 85 | "eslint": "^8.0.0", 86 | "eslint-config-prettier": "^10.1.5", 87 | "eslint-plugin-jsdoc": "^50.6.17", 88 | "eslint-plugin-react": "^7.37.5", 89 | "eslint-plugin-react-hooks": "^5.2.0", 90 | "imports-loader": "^5.0.0", 91 | "semver": "^7.7.2", 92 | "terser-webpack-plugin": "^5.3.14", 93 | "webpack-subresource-integrity": "^5.1.0", 94 | "webpack-virtual-modules": "^0.6.2" 95 | }, 96 | "resolutions": { 97 | "rxjs": "7.8.1", 98 | "jackspeak": "2.1.1" 99 | }, 100 | "packageManager": "yarn@1.22.19" 101 | } 102 | -------------------------------------------------------------------------------- /pkg/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/data" 8 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 9 | ) 10 | 11 | var ( 12 | ErrorQueryDataNotImplemented = errors.New("query data not implemented") 13 | ErrorInvalidResourceCallQuery = errors.New("invalid resource query") 14 | ErrorFailedUnmarshalingResourceQuery = errors.New("failed to unmarshal resource query") 15 | ErrorQueryParsingNotImplemented = errors.New("query parsing not implemented yet") 16 | ErrorUnmarshalingSettings = errors.New("error while unmarshaling settings") 17 | ErrorInvalidSentryConfig = errors.New("invalid sentry configuration") 18 | ErrorInvalidAuthToken = errors.New("empty or invalid auth token found") 19 | ErrorInvalidOrganizationSlug = errors.New("invalid or empty organization slug") 20 | ErrorUnknownQueryType = errors.New("unknown query type") 21 | ) 22 | 23 | // GetErrorResponse returns a DataResponse with an error frame. 24 | func GetErrorResponse(response backend.DataResponse, executedQueryString string, err error) backend.DataResponse { 25 | if err != nil { 26 | errResponse := errorsource.Response(err) 27 | response.Error = errResponse.Error 28 | response.ErrorSource = errResponse.ErrorSource 29 | frame := data.NewFrame("Error") 30 | frame.Meta = &data.FrameMeta{ 31 | ExecutedQueryString: executedQueryString, 32 | } 33 | response.Frames = append(response.Frames, frame) 34 | return response 35 | } 36 | return response 37 | } 38 | -------------------------------------------------------------------------------- /pkg/framer/converters.go: -------------------------------------------------------------------------------- 1 | package framer 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/data" 9 | "github.com/grafana/sentry-datasource/pkg/sentry" 10 | ) 11 | 12 | func ConvertMetricsResponseToFrame(frameName string, metrics sentry.MetricsResponse) (*data.Frame, error) { 13 | if len(metrics.Intervals) == 0 { 14 | return data.NewFrameOfFieldTypes(frameName, 0), nil 15 | } 16 | frame := data.NewFrameOfFieldTypes(frameName, len(metrics.Intervals)) 17 | field := data.NewField("Timestamp", nil, metrics.Intervals) 18 | frame.Fields = append(frame.Fields, field) 19 | for _, group := range metrics.Groups { 20 | for valueName, series := range group.Series { 21 | array, isArray := series.([]interface{}) 22 | if !isArray { 23 | return nil, fmt.Errorf("expected array, got %T", series) 24 | } 25 | field := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(array)) 26 | field.Name = valueName 27 | for _, by := range group.By { 28 | float, ok := by.(float64) 29 | if ok { 30 | field.Name = strconv.FormatFloat(float, 'f', 0, 64) 31 | } else { 32 | field.Name = by.(string) 33 | } 34 | } 35 | for index, item := range array { 36 | value, ok := item.(float64) 37 | if ok { 38 | field.Set(index, &value) 39 | } else if item != nil { 40 | return nil, fmt.Errorf("expected float64 or null, got %T", item) 41 | } 42 | } 43 | frame.Fields = append(frame.Fields, field) 44 | } 45 | } 46 | return frame, nil 47 | } 48 | 49 | func ConvertStatsV2ResponseToFrame(frameName string, stats sentry.StatsV2Response) (*data.Frame, error) { 50 | if len(stats.Intervals) == 0 { 51 | return data.NewFrameOfFieldTypes(frameName, 0), nil 52 | } 53 | frame := data.NewFrameOfFieldTypes(frameName, len(stats.Intervals)) 54 | field := data.NewField("Timestamp", nil, stats.Intervals) 55 | frame.Fields = append(frame.Fields, field) 56 | for _, group := range stats.Groups { 57 | if len(stats.Intervals) == len(group.Series.SumQuantity) { 58 | field := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(group.Series.SumQuantity)) 59 | for i, sq := range group.Series.SumQuantity { 60 | field.Set(i, float64(sq)) 61 | } 62 | field.Name = "Sum (Quantity)" 63 | field.Labels = data.Labels{} 64 | if group.By.Category != "" { 65 | field.Labels["Category"] = group.By.Category 66 | } 67 | if group.By.Outcome != "" { 68 | field.Labels["Outcome"] = group.By.Outcome 69 | } 70 | if group.By.Reason != "" { 71 | field.Labels["Reason"] = group.By.Reason 72 | } 73 | frame.Fields = append(frame.Fields, field) 74 | } 75 | if len(stats.Intervals) == len(group.Series.SumTimesSeen) { 76 | field := data.NewFieldFromFieldType(data.FieldTypeFloat64, len(group.Series.SumTimesSeen)) 77 | for i, ts := range group.Series.SumTimesSeen { 78 | field.Set(i, float64(ts)) 79 | } 80 | field.Name = "Sum (Times Seen)" 81 | field.Labels = data.Labels{} 82 | if group.By.Category != "" { 83 | field.Labels["Category"] = group.By.Category 84 | } 85 | if group.By.Outcome != "" { 86 | field.Labels["Outcome"] = group.By.Outcome 87 | } 88 | if group.By.Reason != "" { 89 | field.Labels["Reason"] = group.By.Reason 90 | } 91 | frame.Fields = append(frame.Fields, field) 92 | } 93 | } 94 | return frame, nil 95 | } 96 | 97 | func ConvertEventStatsSetToTimestampField(set SentryEventsStatsSet) (*data.Field, error) { 98 | field := data.NewFieldFromFieldType(data.FieldTypeTime, len(set.Data)) 99 | field.Name = "Timestamp" 100 | for index, value := range set.Data { 101 | row, isArray := value.([]interface{}) 102 | if !isArray { 103 | return nil, fmt.Errorf("expected array, got %T", value) 104 | } 105 | timestamp, isFloat64 := row[0].(float64) 106 | if !isFloat64 { 107 | return nil, fmt.Errorf("expected float64, got %T", row[0]) 108 | } 109 | field.Set(index, time.Unix(int64(timestamp), 0)) 110 | } 111 | return field, nil 112 | } 113 | 114 | func ConvertEventStatsSetToField(set SentryEventsStatsSet) (*data.Field, error) { 115 | field := data.NewFieldFromFieldType(data.FieldTypeNullableFloat64, len(set.Data)) 116 | field.Name = set.Name 117 | for index, value := range set.Data { 118 | row, isArray := value.([]interface{}) 119 | if !isArray { 120 | return nil, fmt.Errorf("expected array, got %T", value) 121 | } 122 | valueArray, isArray := row[1].([]interface{}) 123 | if !isArray { 124 | return nil, fmt.Errorf("expected array, got %T", row[1]) 125 | } 126 | valueObject, isObject := valueArray[0].(map[string]interface{}) 127 | if !isObject { 128 | return nil, fmt.Errorf("expected JSON object, got %T", valueArray[0]) 129 | } 130 | count, ok := valueObject["count"].(float64) 131 | if ok { 132 | field.Set(index, &count) 133 | } else if valueObject["count"] != nil { 134 | return nil, fmt.Errorf("expected float64 or null, got %T", valueObject["count"]) 135 | } 136 | } 137 | return field, nil 138 | } 139 | 140 | func ConvertEventsStatsResponseToFrame(frameName string, eventsStats sentry.SentryEventsStats) (*data.Frame, error) { 141 | sets, error := ExtractDataSets("", eventsStats) 142 | if error != nil { 143 | return nil, error 144 | } 145 | frame := data.NewFrameOfFieldTypes(frameName, 0) 146 | 147 | for index, set := range sets { 148 | if index == 0 { 149 | timestampField, error := ConvertEventStatsSetToTimestampField(set) 150 | if error != nil { 151 | return nil, error 152 | } 153 | frame.Fields = append(frame.Fields, timestampField) 154 | } 155 | field, error := ConvertEventStatsSetToField(set) 156 | if error != nil { 157 | return nil, error 158 | } 159 | frame.Fields = append(frame.Fields, field) 160 | } 161 | return frame, nil 162 | } 163 | -------------------------------------------------------------------------------- /pkg/framer/framer.go: -------------------------------------------------------------------------------- 1 | package framer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/data" 7 | "github.com/grafana/sentry-datasource/pkg/query" 8 | ) 9 | 10 | type SentryEventsStatsSet struct { 11 | Name string 12 | Data []interface{} 13 | } 14 | 15 | // GetFrameName returns a frame name with the refID appended in parentheses. 16 | func GetFrameName(frameName string, refID string) string { 17 | return fmt.Sprintf("%s (%s)", frameName, refID) 18 | } 19 | 20 | // GetFrameLabels returns a slice of field names from a frame. 21 | func GetFrameLabels(frame *data.Frame) []string { 22 | labels := make([]string, len(frame.Fields)) 23 | for i := range frame.Fields { 24 | labels[i] = frame.Fields[i].Name 25 | } 26 | return labels 27 | } 28 | 29 | func ExtractDataSets(namePrefix string, rawData map[string]interface{}) ([]SentryEventsStatsSet, error) { 30 | var sets []SentryEventsStatsSet 31 | for key, dataSetOrGroup := range rawData { 32 | if key == "data" { 33 | set, isArray := dataSetOrGroup.([]interface{}) 34 | if !isArray { 35 | return nil, fmt.Errorf("expected array, got %T", dataSetOrGroup) 36 | } 37 | return append(sets, SentryEventsStatsSet{ 38 | Name: namePrefix, 39 | Data: set, 40 | }), nil 41 | } 42 | if key == "order" { 43 | continue 44 | } 45 | child, isObject := dataSetOrGroup.(map[string]interface{}) 46 | if !isObject { 47 | continue 48 | } 49 | name := key 50 | if len(namePrefix) != 0 && len(key) != 0 { 51 | name = fmt.Sprintf("%s: %s", namePrefix, key) 52 | } 53 | nestedSets, error := ExtractDataSets(name, child) 54 | if error != nil { 55 | return nil, error 56 | } 57 | sets = append(sets, nestedSets...) 58 | } 59 | return sets, nil 60 | } 61 | 62 | func UpdateFrameMeta(frame *data.Frame, executedQueryString string, query query.SentryQuery, baseURL string, orgSlug string) *data.Frame { 63 | frame.Meta = &data.FrameMeta{ 64 | ExecutedQueryString: executedQueryString, 65 | } 66 | 67 | for i := range frame.Fields { 68 | if frame.Fields[i].Name == "ID" && query.QueryType == "issues" { 69 | frame.Fields[i].Config = &data.FieldConfig{ 70 | Links: []data.DataLink{ 71 | { 72 | Title: "Open in Sentry", 73 | URL: fmt.Sprintf("%s/organizations/%s/issues/${__data.fields[\"ID\"]}/", baseURL, orgSlug), 74 | TargetBlank: true, 75 | }, 76 | }, 77 | } 78 | } 79 | 80 | if frame.Fields[i].Name == "ID" && query.QueryType == "events" { 81 | frame.Fields[i].Config = &data.FieldConfig{ 82 | Links: []data.DataLink{ 83 | { 84 | Title: "Open in Sentry", 85 | URL: fmt.Sprintf("%s/organizations/%s/discover/${__data.fields[\"Project\"]}:${__data.fields[\"ID\"]}/", baseURL, orgSlug), 86 | TargetBlank: true, 87 | }, 88 | }, 89 | } 90 | } 91 | } 92 | 93 | return frame 94 | } 95 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 9 | 10 | "github.com/grafana/sentry-datasource/pkg/plugin" 11 | ) 12 | 13 | func main() { 14 | backend.SetupPluginEnvironment(plugin.PluginID) 15 | 16 | if err := datasource.Manage("grafana-sentry-datasource", plugin.NewDatasource, datasource.ManageOpts{}); err != nil { 17 | log.DefaultLogger.Error(err.Error()) 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkg/mocker/doer.go: -------------------------------------------------------------------------------- 1 | package mocker 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "runtime" 10 | 11 | "github.com/grafana/grafana-plugin-sdk-go/backend" 12 | ) 13 | 14 | func Mock(req *http.Request) (*http.Response, error) { 15 | backend.Logger.Error("responding via mock", "method", req.Method, "url", req.URL.String()) 16 | if req.Header.Get("Authorization") == "" { 17 | return &http.Response{ 18 | StatusCode: http.StatusUnauthorized, 19 | Status: "401 Unauthorized", 20 | Body: io.NopCloser(bytes.NewBufferString(`{ "detail": "Authentication credentials were not provided." }`)), 21 | }, nil 22 | } 23 | if req.Header.Get("Authorization") != "Bearer mock-token" { 24 | return &http.Response{ 25 | StatusCode: http.StatusUnauthorized, 26 | Status: "401 Unauthorized", 27 | Body: io.NopCloser(bytes.NewBufferString(`{ "detail": "Invalid token" }`)), 28 | }, nil 29 | } 30 | var body = io.NopCloser(bytes.NewBufferString("{}")) 31 | switch req.URL.Path { 32 | default: 33 | file := filepath.Join(basePath(), "./testdata/projects.json") 34 | if b, err := os.ReadFile(file); err == nil { 35 | body = io.NopCloser(bytes.NewReader(b)) 36 | } 37 | } 38 | return &http.Response{ 39 | StatusCode: http.StatusOK, 40 | Status: "200 OK", 41 | Body: body, 42 | }, nil 43 | } 44 | 45 | func basePath() string { 46 | mockSource, present := os.LookupEnv("GRAFANA_SENTRY_MOCK_SOURCE") 47 | if present && mockSource == "local_mock" { 48 | return filepath.Dir("/mock/grafana-sentry-datasource/") 49 | } 50 | _, b, _, _ := runtime.Caller(0) 51 | return filepath.Dir(b) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/plugin/plugin.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" 11 | "github.com/grafana/sentry-datasource/pkg/errors" 12 | "github.com/grafana/sentry-datasource/pkg/handlers" 13 | "github.com/grafana/sentry-datasource/pkg/query" 14 | "github.com/grafana/sentry-datasource/pkg/sentry" 15 | "github.com/grafana/sentry-datasource/pkg/util" 16 | ) 17 | 18 | var ( 19 | _ backend.QueryDataHandler = (*SentryDatasource)(nil) 20 | _ backend.CheckHealthHandler = (*SentryDatasource)(nil) 21 | _ instancemgmt.InstanceDisposer = (*SentryDatasource)(nil) 22 | ) 23 | 24 | const ( 25 | PluginID string = "grafana-sentry-datasource" 26 | ) 27 | 28 | // SentryDatasource is a struct that represents the Sentry datasource. 29 | type SentryDatasource struct { 30 | backend.CallResourceHandler 31 | client sentry.SentryClient 32 | } 33 | 34 | // NewDatasourceInstance creates an instance of the SentryDatasource. It is a helper 35 | // function that is mostly used for testing. 36 | func NewDatasourceInstance(sc *sentry.SentryClient) *SentryDatasource { 37 | return &SentryDatasource{ 38 | client: *sc, 39 | } 40 | } 41 | 42 | // NewDatasource creates an instance factory for the SentryDatasource. It is consumed by 43 | // the `datasource.Manage` function to create a new instance of the datasource. 44 | func NewDatasource(ctx context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 45 | settings, err := GetSettings(s) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | // we need these options to load the secure proxy configuration 51 | opt, err := s.HTTPClientOptions(ctx) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | hc, err := httpclient.New(opt) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | sc, err := sentry.NewSentryClient(settings.URL, settings.OrgSlug, settings.authToken, hc) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | ds := NewDatasourceInstance(sc) 67 | 68 | // these are used to proxy requests to Sentry 69 | ds.CallResourceHandler = httpadapter.New(ds.getResourceRouter()) 70 | 71 | return ds, nil 72 | } 73 | 74 | // Dispose is a callback that is called when the datasource is being disposed. 75 | func (ds *SentryDatasource) Dispose() { 76 | // this is a no-op for now 77 | } 78 | 79 | // QueryData is the entrypoint for handling data queries from Grafana. 80 | func (ds *SentryDatasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { 81 | // we don't pass a PluginContext while testing, so we need to check if it's nil 82 | // before logging the datasource name to avoid a panic 83 | if req.PluginContext.DataSourceInstanceSettings != nil { 84 | backend.Logger.Debug("Query", "datasource", req.PluginContext.DataSourceInstanceSettings.Name) 85 | } 86 | 87 | response := backend.NewQueryDataResponse() 88 | 89 | for _, q := range req.Queries { 90 | res := createResponse(q, ds.client) 91 | response.Responses[q.RefID] = res 92 | } 93 | 94 | return response, nil 95 | } 96 | 97 | // createResponse is a helper function that creates a response for a given query. 98 | func createResponse(backendQuery backend.DataQuery, client sentry.SentryClient) backend.DataResponse { 99 | response := backend.DataResponse{} 100 | 101 | query, err := query.GetQuery(backendQuery) 102 | if err != nil { 103 | return errors.GetErrorResponse(response, "", err) 104 | } 105 | 106 | switch query.QueryType { 107 | case "issues": 108 | return handlers.HandleIssues(client, query, backendQuery, response) 109 | case "events": 110 | return handlers.HandleEvents(client, query, backendQuery, response) 111 | case "eventsStats": 112 | return handlers.HandleEventsStats(client, query, backendQuery, response) 113 | case "metrics": 114 | return handlers.HandleMetrics(client, query, backendQuery, response) 115 | case "statsV2": 116 | return handlers.HandleStatsV2(client, query, backendQuery, response) 117 | default: 118 | response.Error = errors.ErrorUnknownQueryType 119 | response.ErrorSource = backend.ErrorSourceDownstream 120 | } 121 | 122 | return response 123 | } 124 | 125 | // CheckHealth is a callback that is called when Grafana requests a health check for the datasource during setup. 126 | func (ds *SentryDatasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { 127 | projects, err := ds.client.GetProjects(ds.client.OrgSlug, false) 128 | if err != nil { 129 | errorMessage := err.Error() 130 | return &backend.CheckHealthResult{ 131 | Status: backend.HealthStatusError, 132 | Message: errorMessage, 133 | }, nil 134 | } 135 | 136 | return &backend.CheckHealthResult{ 137 | Status: backend.HealthStatusOk, 138 | Message: fmt.Sprintf("%s. %v projects found.", util.SuccessfulHealthCheckMessage, len(projects)), 139 | }, nil 140 | } 141 | -------------------------------------------------------------------------------- /pkg/plugin/plugin_test.go: -------------------------------------------------------------------------------- 1 | package plugin_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 10 | . "github.com/grafana/sentry-datasource/pkg/errors" 11 | "github.com/grafana/sentry-datasource/pkg/plugin" 12 | "github.com/grafana/sentry-datasource/pkg/util" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func Test_QueryData(t *testing.T) { 17 | t.Run("invalid query should throw error", func(t *testing.T) { 18 | ds := plugin.NewDatasourceInstance(util.NewFakeClient(util.FakeDoer{})) 19 | ctx := context.TODO() 20 | res, _ := ds.QueryData(ctx, &backend.QueryDataRequest{Queries: []backend.DataQuery{{RefID: "A", JSON: []byte(`{}`)}}}) 21 | assert.Equal(t, ErrorUnknownQueryType, res.Responses["A"].Error) 22 | }) 23 | 24 | t.Run("invalid response should capture error", func(t *testing.T) { 25 | client := util.NewFakeClient(util.FakeDoer{Body: "{}", ExpectedStatusCode: 400, ExpectedStatus: "400 Unknown error"}) 26 | ds := plugin.NewDatasourceInstance(client) 27 | ctx := context.TODO() 28 | res, _ := ds.QueryData(ctx, &backend.QueryDataRequest{Queries: []backend.DataQuery{{RefID: "A", JSON: []byte(`{ 29 | "queryType" : "issues" 30 | }`)}}}) 31 | assert.NotNil(t, res.Responses["A"].Error) 32 | assert.Equal(t, errorsource.SourceError(backend.ErrorSourceDownstream, errors.New("400 Unknown error"), false), res.Responses["A"].Error) 33 | }) 34 | 35 | t.Run("invalid response with valid status code should capture error", func(t *testing.T) { 36 | client := util.NewFakeClient(util.FakeDoer{Body: "{}"}) 37 | ds := plugin.NewDatasourceInstance(client) 38 | ctx := context.TODO() 39 | res, _ := ds.QueryData(ctx, &backend.QueryDataRequest{Queries: []backend.DataQuery{{RefID: "A", JSON: []byte(`{ 40 | "queryType" : "issues" 41 | }`)}}}) 42 | assert.NotNil(t, res.Responses["A"].Error) 43 | assert.Equal(t, "json: cannot unmarshal object into Go value of type []sentry.SentryIssue", res.Responses["A"].Error.Error()) 44 | }) 45 | 46 | t.Run("invalid response should capture error detail if available", func(t *testing.T) { 47 | client := util.NewFakeClient(util.FakeDoer{Body: `{ "detail" : "simulated error" }`, ExpectedStatusCode: 400, ExpectedStatus: "400 Unknown error"}) 48 | ds := plugin.NewDatasourceInstance(client) 49 | ctx := context.TODO() 50 | res, _ := ds.QueryData(ctx, &backend.QueryDataRequest{Queries: []backend.DataQuery{{RefID: "A", JSON: []byte(`{ 51 | "queryType" : "issues" 52 | }`)}}}) 53 | assert.NotNil(t, res.Responses["A"].Error) 54 | assert.Equal(t, errorsource.SourceError(backend.ErrorSourceDownstream, errors.New("400 Unknown error simulated error"), false), res.Responses["A"].Error) 55 | }) 56 | } 57 | 58 | func Test_CheckHealth(t *testing.T) { 59 | t.Run("invalid auth token should throw error", func(t *testing.T) { 60 | ds := plugin.NewDatasourceInstance(util.NewFakeClient(util.FakeDoer{AuthToken: "incorrect-token"})) 61 | ctx := context.TODO() 62 | req := &backend.CheckHealthRequest{} 63 | hc, err := ds.CheckHealth(ctx, req) 64 | assert.Nil(t, err) 65 | assert.Equal(t, backend.HealthStatusError, hc.Status) 66 | assert.Equal(t, "401 Unauthorized", hc.Message) 67 | }) 68 | 69 | t.Run("valid auth token should not throw error", func(t *testing.T) { 70 | ds := plugin.NewDatasourceInstance(util.NewFakeClient(util.FakeDoer{Body: "[]"})) 71 | ctx := context.TODO() 72 | req := &backend.CheckHealthRequest{} 73 | hc, err := ds.CheckHealth(ctx, req) 74 | assert.Nil(t, err) 75 | assert.Equal(t, backend.HealthStatusOk, hc.Status) 76 | assert.Equal(t, "plugin health check successful. 0 projects found.", hc.Message) 77 | }) 78 | 79 | t.Run("should return organizations length", func(t *testing.T) { 80 | ds := plugin.NewDatasourceInstance(util.NewFakeClient(util.FakeDoer{Body: "[{},{}]"})) 81 | ctx := context.TODO() 82 | req := &backend.CheckHealthRequest{} 83 | hc, err := ds.CheckHealth(ctx, req) 84 | assert.Nil(t, err) 85 | assert.Equal(t, backend.HealthStatusOk, hc.Status) 86 | assert.Equal(t, "plugin health check successful. 2 projects found.", hc.Message) 87 | }) 88 | 89 | t.Run("invalid response should throw error", func(t *testing.T) { 90 | ds := plugin.NewDatasourceInstance(util.NewFakeClient(util.FakeDoer{Body: "{}"})) 91 | ctx := context.TODO() 92 | req := &backend.CheckHealthRequest{} 93 | hc, err := ds.CheckHealth(ctx, req) 94 | assert.Nil(t, err) 95 | assert.Equal(t, backend.HealthStatusError, hc.Status) 96 | assert.Equal(t, "json: cannot unmarshal object into Go value of type []sentry.SentryProject", hc.Message) 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /pkg/plugin/router.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | "github.com/grafana/sentry-datasource/pkg/sentry" 9 | ) 10 | 11 | func (ds *SentryDatasource) getResourceRouter() *mux.Router { 12 | router := mux.NewRouter() 13 | router.HandleFunc("/api/0/organizations", ds.withDatasourceHandler(GetOrganizationsHandler)).Methods("GET") 14 | router.HandleFunc("/api/0/organizations/{organization_slug}/projects", ds.withDatasourceHandler(GetProjectsHandler)).Methods("GET") 15 | router.HandleFunc("/api/0/organizations/{organization_slug}/tags", ds.withDatasourceHandler(GetTagsHandler)).Methods("GET") 16 | router.HandleFunc("/api/0/organizations/{organization_slug}/teams", ds.withDatasourceHandler(GetOrganizationTeamsHandler)).Methods("GET") 17 | router.HandleFunc("/api/0/teams/{organization_slug}/{team_slug}/projects", ds.withDatasourceHandler(GetTeamsProjectsHandler)).Methods("GET") 18 | router.NotFoundHandler = http.HandlerFunc(ds.withDatasourceHandler(DefaultResourceHandler)) 19 | return router 20 | } 21 | 22 | func (ds *SentryDatasource) withDatasourceHandler(getHandler func(d *sentry.SentryClient) http.HandlerFunc) func(rw http.ResponseWriter, r *http.Request) { 23 | return func(rw http.ResponseWriter, r *http.Request) { 24 | h := getHandler(&ds.client) 25 | h.ServeHTTP(rw, r) 26 | } 27 | } 28 | 29 | func GetOrganizationsHandler(client *sentry.SentryClient) http.HandlerFunc { 30 | return func(rw http.ResponseWriter, r *http.Request) { 31 | orgs, err := client.GetOrganizations() 32 | writeResponse(orgs, err, rw) 33 | } 34 | } 35 | 36 | func GetProjectsHandler(client *sentry.SentryClient) http.HandlerFunc { 37 | return func(rw http.ResponseWriter, r *http.Request) { 38 | orgSlug := mux.Vars(r)["organization_slug"] 39 | if orgSlug == "" { 40 | http.Error(rw, "invalid orgSlug", http.StatusBadRequest) 41 | return 42 | } 43 | orgs, err := client.GetProjects(orgSlug, true) 44 | writeResponse(orgs, err, rw) 45 | } 46 | } 47 | 48 | func GetTagsHandler(client *sentry.SentryClient) http.HandlerFunc { 49 | return func(rw http.ResponseWriter, r *http.Request) { 50 | orgSlug := mux.Vars(r)["organization_slug"] 51 | if orgSlug == "" { 52 | http.Error(rw, "invalid orgSlug", http.StatusBadRequest) 53 | return 54 | } 55 | orgs, err := client.GetTags(orgSlug, true) 56 | writeResponse(orgs, err, rw) 57 | } 58 | } 59 | 60 | func GetTeamsProjectsHandler(client *sentry.SentryClient) http.HandlerFunc { 61 | return func(rw http.ResponseWriter, r *http.Request) { 62 | orgSlug := mux.Vars(r)["organization_slug"] 63 | if orgSlug == "" { 64 | http.Error(rw, "invalid orgSlug", http.StatusBadRequest) 65 | return 66 | } 67 | teamSlug := mux.Vars(r)["team_slug"] 68 | if teamSlug == "" { 69 | http.Error(rw, "invalid teamSlug", http.StatusBadRequest) 70 | return 71 | } 72 | projects, err := client.GetTeamsProjects(orgSlug, teamSlug) 73 | writeResponse(projects, err, rw) 74 | } 75 | } 76 | 77 | func GetOrganizationTeamsHandler(client *sentry.SentryClient) http.HandlerFunc { 78 | return func(rw http.ResponseWriter, r *http.Request) { 79 | orgSlug := mux.Vars(r)["organization_slug"] 80 | if orgSlug == "" { 81 | http.Error(rw, "invalid orgSlug", http.StatusBadRequest) 82 | return 83 | } 84 | teams, err := client.ListOrganizationTeams(orgSlug, true) 85 | writeResponse(teams, err, rw) 86 | } 87 | } 88 | 89 | func DefaultResourceHandler(client *sentry.SentryClient) http.HandlerFunc { 90 | return func(rw http.ResponseWriter, r *http.Request) { 91 | http.Error(rw, "not a valid resource call", http.StatusNotImplemented) 92 | } 93 | } 94 | 95 | func writeResponse(resp interface{}, err error, rw http.ResponseWriter) { 96 | if err != nil { 97 | http.Error(rw, err.Error(), http.StatusInternalServerError) 98 | return 99 | } 100 | b, err := json.Marshal(resp) 101 | if err != nil { 102 | http.Error(rw, err.Error(), http.StatusInternalServerError) 103 | return 104 | } 105 | rw.Write(b) //nolint 106 | } 107 | -------------------------------------------------------------------------------- /pkg/plugin/router_test.go: -------------------------------------------------------------------------------- 1 | package plugin_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/grafana/sentry-datasource/pkg/plugin" 10 | "github.com/grafana/sentry-datasource/pkg/util" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func getFakeRouter(routes map[string]func(http.ResponseWriter, *http.Request)) *mux.Router { 15 | router := mux.NewRouter() 16 | for path, handler := range routes { 17 | router.HandleFunc(path, handler) 18 | } 19 | return router 20 | } 21 | 22 | func TestGetOrganizationsHandler(t *testing.T) { 23 | t.Run("should return valid list of organizations", func(t *testing.T) { 24 | fakeOrgs := `[{"dateCreated":"0001-01-01T00:00:00Z","id":"","isEarlyAdopter":false,"name":"","require2FA":false,"slug":"","status":{"id":"","name":""},"avatar":{"avatarType":""}},{"dateCreated":"0001-01-01T00:00:00Z","id":"","isEarlyAdopter":false,"name":"","require2FA":false,"slug":"","status":{"id":"","name":""},"avatar":{"avatarType":""}},{"dateCreated":"0001-01-01T00:00:00Z","id":"","isEarlyAdopter":false,"name":"","require2FA":false,"slug":"","status":{"id":"","name":""},"avatar":{"avatarType":""}}]` 25 | req, _ := http.NewRequest("GET", "/api/0/organizations", nil) 26 | client := util.NewFakeClient(util.FakeDoer{Body: fakeOrgs}) 27 | handler := plugin.GetOrganizationsHandler(client) 28 | rr := httptest.NewRecorder() 29 | router := getFakeRouter(map[string]func(http.ResponseWriter, *http.Request){"/api/0/organizations": handler}) 30 | router.ServeHTTP(rr, req) 31 | assert.Equal(t, http.StatusOK, rr.Code) 32 | assert.Equal(t, fakeOrgs, rr.Body.String()) 33 | }) 34 | } 35 | 36 | func TestGetOrganizationTeamsHandler(t *testing.T) { 37 | fakeTeams := "[{\"avatar\":{\"avatarType\":\"\",\"avatarUuid\":null},\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isMember\":false,\"isPending\":false,\"memberCount\":0,\"name\":\"\",\"projects\":null,\"slug\":\"\"},{\"avatar\":{\"avatarType\":\"\",\"avatarUuid\":null},\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isMember\":false,\"isPending\":false,\"memberCount\":0,\"name\":\"\",\"projects\":null,\"slug\":\"\"},{\"avatar\":{\"avatarType\":\"\",\"avatarUuid\":null},\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isMember\":false,\"isPending\":false,\"memberCount\":0,\"name\":\"\",\"projects\":null,\"slug\":\"\"}]" 38 | t.Run("valid org slug should return results", func(t *testing.T) { 39 | req, _ := http.NewRequest("GET", "/api/0/organizations/foo/teams", nil) 40 | client := util.NewFakeClient(util.FakeDoer{Body: fakeTeams}) 41 | handler := plugin.GetOrganizationTeamsHandler(client) 42 | rr := httptest.NewRecorder() 43 | router := getFakeRouter(map[string]func(http.ResponseWriter, *http.Request){"/api/0/organizations/{organization_slug}/teams": handler}) 44 | router.ServeHTTP(rr, req) 45 | assert.Equal(t, http.StatusOK, rr.Code) 46 | assert.Equal(t, fakeTeams, rr.Body.String()) 47 | }) 48 | } 49 | func TestGetProjectsHandler(t *testing.T) { 50 | fakeProjects := "[{\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isBookmarked\":false,\"isMember\":false,\"environments\":null,\"name\":\"\",\"slug\":\"\",\"team\":{\"id\":\"\",\"name\":\"\",\"slug\":\"\"},\"teams\":null},{\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isBookmarked\":false,\"isMember\":false,\"environments\":null,\"name\":\"\",\"slug\":\"\",\"team\":{\"id\":\"\",\"name\":\"\",\"slug\":\"\"},\"teams\":null},{\"dateCreated\":\"0001-01-01T00:00:00Z\",\"hasAccess\":false,\"id\":\"\",\"isBookmarked\":false,\"isMember\":false,\"environments\":null,\"name\":\"\",\"slug\":\"\",\"team\":{\"id\":\"\",\"name\":\"\",\"slug\":\"\"},\"teams\":null}]" 51 | t.Run("valid org slug should return results", func(t *testing.T) { 52 | req, _ := http.NewRequest("GET", "/api/0/organizations/foo/projects", nil) 53 | client := util.NewFakeClient(util.FakeDoer{Body: fakeProjects}) 54 | handler := plugin.GetProjectsHandler(client) 55 | rr := httptest.NewRecorder() 56 | router := getFakeRouter(map[string]func(http.ResponseWriter, *http.Request){"/api/0/organizations/{organization_slug}/projects": handler}) 57 | router.ServeHTTP(rr, req) 58 | assert.Equal(t, http.StatusOK, rr.Code) 59 | assert.Equal(t, fakeProjects, rr.Body.String()) 60 | }) 61 | } 62 | func TestDefaultResourceHandler(t *testing.T) { 63 | t.Run("unknown or invalid route should throw error", func(t *testing.T) { 64 | req, _ := http.NewRequest("GET", "/", nil) 65 | client := util.NewFakeClient(util.FakeDoer{Body: ""}) 66 | handler := plugin.DefaultResourceHandler(client) 67 | rr := httptest.NewRecorder() 68 | router := getFakeRouter(map[string]func(http.ResponseWriter, *http.Request){"/": handler}) 69 | router.ServeHTTP(rr, req) 70 | assert.Equal(t, http.StatusNotImplemented, rr.Code) 71 | assert.Equal(t, "not a valid resource call\n", rr.Body.String()) 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/plugin/settings.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/sentry-datasource/pkg/errors" 8 | "github.com/grafana/sentry-datasource/pkg/util" 9 | ) 10 | 11 | type SentryConfig struct { 12 | URL string `json:"url"` 13 | OrgSlug string `json:"orgSlug"` 14 | authToken string `json:"-"` 15 | } 16 | 17 | func (sc *SentryConfig) Validate() error { 18 | if sc.URL == "" { 19 | return errors.ErrorInvalidSentryConfig 20 | } 21 | if sc.OrgSlug == "" { 22 | return errors.ErrorInvalidOrganizationSlug 23 | } 24 | if sc.authToken == "" { 25 | return errors.ErrorInvalidAuthToken 26 | } 27 | return nil 28 | } 29 | 30 | func GetSettings(s backend.DataSourceInstanceSettings) (*SentryConfig, error) { 31 | config := &SentryConfig{} 32 | if err := json.Unmarshal(s.JSONData, config); err != nil { 33 | backend.Logger.Error(errors.ErrorUnmarshalingSettings.Error()) 34 | return nil, errors.ErrorUnmarshalingSettings 35 | } 36 | if config.URL == "" { 37 | backend.Logger.Info("applying default sentry URL", "sentry url", util.DefaultSentryURL) 38 | config.URL = util.DefaultSentryURL 39 | } 40 | if config.OrgSlug == "" { 41 | return nil, errors.ErrorInvalidOrganizationSlug 42 | } 43 | if authToken, ok := s.DecryptedSecureJSONData["authToken"]; ok { 44 | config.authToken = authToken 45 | } 46 | if config.authToken == "" { 47 | backend.Logger.Error(errors.ErrorInvalidAuthToken.Error()) 48 | return nil, errors.ErrorInvalidAuthToken 49 | } 50 | return config, config.Validate() 51 | } 52 | -------------------------------------------------------------------------------- /pkg/plugin/settings_test.go: -------------------------------------------------------------------------------- 1 | package plugin_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/sentry-datasource/pkg/errors" 8 | "github.com/grafana/sentry-datasource/pkg/plugin" 9 | "github.com/grafana/sentry-datasource/pkg/util" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestGetSettings(t *testing.T) { 14 | t.Run("invalid settings should throw unmarshal error", func(t *testing.T) { 15 | _, err := plugin.GetSettings(backend.DataSourceInstanceSettings{}) 16 | assert.NotNil(t, err) 17 | assert.Equal(t, errors.ErrorUnmarshalingSettings, err) 18 | _, err = plugin.GetSettings(backend.DataSourceInstanceSettings{JSONData: []byte(`invalid json`)}) 19 | assert.NotNil(t, err) 20 | assert.Equal(t, errors.ErrorUnmarshalingSettings, err) 21 | }) 22 | t.Run("missing org slug should throw error", func(t *testing.T) { 23 | _, err := plugin.GetSettings(backend.DataSourceInstanceSettings{JSONData: []byte(`{}`)}) 24 | assert.NotNil(t, err) 25 | assert.Equal(t, errors.ErrorInvalidOrganizationSlug, err) 26 | }) 27 | t.Run("missing auth token should throw error", func(t *testing.T) { 28 | _, err := plugin.GetSettings(backend.DataSourceInstanceSettings{JSONData: []byte(`{ "orgSlug": "foo" }`)}) 29 | assert.NotNil(t, err) 30 | assert.Equal(t, errors.ErrorInvalidAuthToken, err) 31 | }) 32 | t.Run("valid settings should correctly parsed and default url should apply", func(t *testing.T) { 33 | settings, err := plugin.GetSettings(backend.DataSourceInstanceSettings{JSONData: []byte(`{ "orgSlug": "foo" }`), DecryptedSecureJSONData: map[string]string{"authToken": "bar"}}) 34 | assert.Nil(t, err) 35 | assert.Equal(t, util.DefaultSentryURL, settings.URL) 36 | assert.Equal(t, "foo", settings.OrgSlug) 37 | }) 38 | t.Run("url override should apply", func(t *testing.T) { 39 | settings, err := plugin.GetSettings(backend.DataSourceInstanceSettings{JSONData: []byte(`{ "url" : "https://foo.com", "orgSlug": "foo" }`), DecryptedSecureJSONData: map[string]string{"authToken": "bar"}}) 40 | assert.Nil(t, err) 41 | assert.Equal(t, "https://foo.com", settings.URL) 42 | assert.Equal(t, "foo", settings.OrgSlug) 43 | }) 44 | } 45 | 46 | func TestSentryConfig_validate(t *testing.T) { 47 | t.Run("invalid URL should throw error", func(t *testing.T) { 48 | sc := &plugin.SentryConfig{} 49 | err := sc.Validate() 50 | assert.NotNil(t, err) 51 | assert.Equal(t, errors.ErrorInvalidSentryConfig, err) 52 | }) 53 | t.Run("invalid org slug should throw error", func(t *testing.T) { 54 | sc := &plugin.SentryConfig{URL: "https://foo.com"} 55 | err := sc.Validate() 56 | assert.NotNil(t, err) 57 | assert.Equal(t, errors.ErrorInvalidOrganizationSlug, err) 58 | }) 59 | t.Run("invalid password should throw error", func(t *testing.T) { 60 | sc := &plugin.SentryConfig{URL: "https://foo.com", OrgSlug: "foo"} 61 | err := sc.Validate() 62 | assert.NotNil(t, err) 63 | assert.Equal(t, errors.ErrorInvalidAuthToken, err) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/query/helpers.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | ) 8 | 9 | func GetQuery(query backend.DataQuery) (SentryQuery, error) { 10 | var out SentryQuery 11 | err := json.Unmarshal(query.JSON, &out) 12 | return out, err 13 | } 14 | -------------------------------------------------------------------------------- /pkg/query/model.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type SentryQuery struct { 4 | QueryType string `json:"queryType"` 5 | ProjectIds []string `json:"projectIds,omitempty"` 6 | Environments []string `json:"environments,omitempty"` 7 | IssuesQuery string `json:"issuesQuery,omitempty"` 8 | IssuesSort string `json:"issuesSort,omitempty"` 9 | IssuesLimit int64 `json:"issuesLimit,omitempty"` 10 | EventsQuery string `json:"eventsQuery,omitempty"` 11 | EventsFields []string `json:"eventsFields,omitempty"` 12 | EventsSort string `json:"eventsSort,omitempty"` 13 | EventsSortDirection string `json:"eventsSortDirection,omitempty"` 14 | EventsLimit int64 `json:"eventsLimit,omitempty"` 15 | EventsStatsQuery string `json:"eventsStatsQuery,omitempty"` 16 | EventsStatsYAxis []string `json:"eventsStatsYAxis,omitempty"` 17 | EventsStatsGroups []string `json:"eventsStatsGroups,omitempty"` 18 | EventsStatsSort string `json:"eventsStatsSort,omitempty"` 19 | EventsStatsLimit int64 `json:"eventsStatsLimit,omitempty"` 20 | MetricsField string `json:"metricsField,omitempty"` 21 | MetricsQuery string `json:"metricsQuery,omitempty"` 22 | MetricsGroupBy string `json:"metricsGroupBy,omitempty"` 23 | MetricsSort string `json:"metricsSort,omitempty"` 24 | MetricsOrder string `json:"metricsOrder,omitempty"` 25 | MetricsLimit int64 `json:"metricsLimit,omitempty"` 26 | StatsCategory []string `json:"statsCategory,omitempty"` 27 | StatsFields []string `json:"statsFields,omitempty"` 28 | StatsGroupBy []string `json:"statsGroupBy,omitempty"` 29 | StatsInterval string `json:"statsInterval,omitempty"` 30 | StatsOutcome []string `json:"statsOutcome,omitempty"` 31 | StatsReason []string `json:"statsReason,omitempty"` 32 | } 33 | -------------------------------------------------------------------------------- /pkg/sentry/client.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/build" 9 | "github.com/grafana/sentry-datasource/pkg/mocker" 10 | ) 11 | 12 | type doer interface { 13 | Do(*http.Request) (*http.Response, error) 14 | } 15 | 16 | // HTTP creates an HTTP client with a 'Do' method. It automatically injects 17 | // the provided api token into every request with an `Authorization: Bearer AuthToken` header 18 | type HTTPClient struct { 19 | doer 20 | pluginId string 21 | version string 22 | authToken string 23 | } 24 | 25 | // NewHTTPClient creates a new AuthHTTP client 26 | func NewHTTPClient(d doer, pluginId string, b build.InfoGetterFunc, authToken string) HTTPClient { 27 | info, err := b() 28 | version := info.Version 29 | if err != nil { 30 | version = "unknown-version" 31 | } 32 | return HTTPClient{d, pluginId, version, authToken} 33 | } 34 | 35 | // Do attaches the sentry authentication header and the User-Agent header to 36 | // the request and passes it to the injected http Doer 37 | func (a HTTPClient) Do(req *http.Request) (*http.Response, error) { 38 | req.Header.Set("User-Agent", fmt.Sprintf("%s/%s", a.pluginId, a.version)) 39 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.authToken)) 40 | _, E2E_GRAFANA_VERSION_PRESENT := os.LookupEnv("E2E_GRAFANA_VERSION") 41 | if E2E_GRAFANA_VERSION_PRESENT { 42 | return mocker.Mock(req) 43 | } 44 | return a.doer.Do(req) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/sentry/client_test.go: -------------------------------------------------------------------------------- 1 | package sentry_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/build" 10 | "github.com/grafana/sentry-datasource/pkg/sentry" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const pluginId string = "grafana-sentry-datasource" 15 | const dummyVersion string = "dummy-version" 16 | const dummyPath string = "/some-path" 17 | const authToken string = "dummy-auth-token" 18 | 19 | func TestHttpClient(t *testing.T) { 20 | t.Run("it attaches the auth header", func(t *testing.T) { 21 | client := &fakeHttpClient{} 22 | c := sentry.NewHTTPClient(client, pluginId, stubBuildInfoProvider, authToken) 23 | req, err := http.NewRequest(http.MethodGet, dummyPath, nil) 24 | require.Nil(t, err) 25 | _, _ = c.Do(req) 26 | require.Equal(t, "Bearer "+authToken, client.req.Header.Get("Authorization")) 27 | }) 28 | 29 | t.Run("it sets a custom user agent with the plugin header", func(t *testing.T) { 30 | client := &fakeHttpClient{} 31 | c := sentry.NewHTTPClient(client, pluginId, stubBuildInfoProvider, authToken) 32 | req, err := http.NewRequest(http.MethodGet, dummyPath, nil) 33 | require.Nil(t, err) 34 | _, _ = c.Do(req) 35 | require.Equal(t, fmt.Sprintf("%s/%s", pluginId, dummyVersion), client.req.UserAgent()) 36 | }) 37 | 38 | t.Run("it sets the version to 'unknown' when the buildInfoProvider returns an error", func(t *testing.T) { 39 | provider := func() (build.Info, error) { 40 | return build.Info{}, errors.New("500 Internal server error") 41 | } 42 | client := &fakeHttpClient{} 43 | c := sentry.NewHTTPClient(client, pluginId, provider, authToken) 44 | req, err := http.NewRequest(http.MethodGet, dummyPath, nil) 45 | require.Nil(t, err) 46 | _, _ = c.Do(req) 47 | require.Equal(t, fmt.Sprintf("%s/%s", pluginId, "unknown-version"), client.req.UserAgent()) 48 | }) 49 | } 50 | 51 | func stubBuildInfoProvider() (build.Info, error) { 52 | return build.Info{ 53 | Version: dummyVersion, 54 | }, nil 55 | } 56 | 57 | type fakeHttpClient struct { 58 | req *http.Request 59 | } 60 | 61 | func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { 62 | c.req = req 63 | return nil, nil 64 | } 65 | -------------------------------------------------------------------------------- /pkg/sentry/constants.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | const ( 4 | PluginID string = "grafana-sentry-datasource" 5 | DefaultSentryURL string = "https://sentry.io" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/sentry/events.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type GetEventsInput struct { 11 | OrganizationSlug string 12 | ProjectIds []string 13 | Environments []string 14 | Fields []string 15 | Query string 16 | From time.Time 17 | To time.Time 18 | Sort string 19 | Limit int64 20 | } 21 | 22 | func (gei *GetEventsInput) ToQuery() string { 23 | urlPath := fmt.Sprintf("/api/0/organizations/%s/events/?", gei.OrganizationSlug) 24 | if gei.Limit < 1 || gei.Limit > 100 { 25 | gei.Limit = 100 26 | } 27 | params := url.Values{} 28 | params.Set("query", gei.Query) 29 | params.Set("start", gei.From.Format("2006-01-02T15:04:05")) 30 | params.Set("end", gei.To.Format("2006-01-02T15:04:05")) 31 | if gei.Sort != "" { 32 | params.Set("sort", gei.Sort) 33 | } 34 | params.Set("per_page", strconv.FormatInt(gei.Limit, 10)) 35 | for _, field := range gei.Fields { 36 | params.Add("field", field) 37 | } 38 | for _, projectId := range gei.ProjectIds { 39 | params.Add("project", projectId) 40 | } 41 | for _, environment := range gei.Environments { 42 | params.Add("environment", environment) 43 | } 44 | return urlPath + params.Encode() 45 | } 46 | 47 | func (sc *SentryClient) GetEvents(gei GetEventsInput) ([]map[string]interface{}, string, error) { 48 | var out struct { 49 | Data []map[string]interface{} `json:"data"` 50 | } 51 | executedQueryString := gei.ToQuery() 52 | err := sc.Fetch(executedQueryString, &out) 53 | return out.Data, sc.BaseURL + executedQueryString, err 54 | } 55 | -------------------------------------------------------------------------------- /pkg/sentry/events_stats.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/url" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | type SentryEventsStats = map[string]interface{} 12 | 13 | type GetEventsStatsInput struct { 14 | OrganizationSlug string 15 | ProjectIds []string 16 | Environments []string 17 | Fields []string 18 | YAxis []string 19 | Query string 20 | From time.Time 21 | To time.Time 22 | Sort string 23 | Interval time.Duration 24 | Limit int64 25 | } 26 | 27 | func FormatSentryInterval(interval time.Duration) string { 28 | if interval.Hours() > 2 { 29 | return fmt.Sprintf("%dh", int(math.Round(interval.Hours()))) 30 | } 31 | if interval.Minutes() > 2 { 32 | return fmt.Sprintf("%dm", int(math.Round(interval.Minutes()))) 33 | } 34 | return fmt.Sprintf("%ds", int(math.Round(interval.Seconds()))) 35 | } 36 | 37 | func (gei *GetEventsStatsInput) ToQuery() string { 38 | urlPath := fmt.Sprintf("/api/0/organizations/%s/events-stats/?", gei.OrganizationSlug) 39 | if gei.Limit < 1 || gei.Limit > 10 { 40 | gei.Limit = 10 41 | } 42 | params := url.Values{} 43 | params.Set("query", gei.Query) 44 | params.Set("start", gei.From.Format("2006-01-02T15:04:05Z07:00")) 45 | params.Set("end", gei.To.Format("2006-01-02T15:04:05Z07:00")) 46 | params.Set("interval", FormatSentryInterval(gei.Interval)) 47 | params.Set("partial", "1") 48 | params.Set("excludeOther", "1") 49 | if gei.Sort != "" { 50 | params.Set("sort", gei.Sort) 51 | } 52 | params.Set("topEvents", strconv.FormatInt(gei.Limit, 10)) 53 | for _, field := range gei.Fields { 54 | params.Add("field", field) 55 | } 56 | for _, field := range gei.YAxis { 57 | params.Add("yAxis", field) 58 | } 59 | for _, projectId := range gei.ProjectIds { 60 | params.Add("project", projectId) 61 | } 62 | for _, environment := range gei.Environments { 63 | params.Add("environment", environment) 64 | } 65 | return urlPath + params.Encode() 66 | } 67 | 68 | func (sc *SentryClient) GetEventsStats(gei GetEventsStatsInput) (SentryEventsStats, string, error) { 69 | var out SentryEventsStats 70 | executedQueryString := gei.ToQuery() 71 | err := sc.Fetch(executedQueryString, &out) 72 | return out, sc.BaseURL + executedQueryString, err 73 | } 74 | -------------------------------------------------------------------------------- /pkg/sentry/issues.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type SentryIssue struct { 11 | ID string `json:"id"` 12 | ShortID string `json:"shortId"` 13 | Title string `json:"title"` 14 | Count string `json:"count"` 15 | UserCount int64 `json:"userCount"` 16 | Status string `json:"status"` 17 | Substatus string `json:"substatus"` 18 | Level string `json:"level"` 19 | Type string `json:"type"` 20 | Platform string `json:"platform"` 21 | FirstSeen time.Time `json:"firstSeen"` 22 | LastSeen time.Time `json:"lastSeen"` 23 | Culprit string `json:"culprit"` 24 | HasSeen bool `json:"hasSeen"` 25 | IsUnhandled bool `json:"isUnhandled"` 26 | Permalink string `json:"permalink"` 27 | IsPublic bool `json:"isPublic"` 28 | Project struct { 29 | ID string `json:"id"` 30 | Name string `json:"name"` 31 | Slug string `json:"slug"` 32 | Platform string `json:"platform"` 33 | } `json:"project"` 34 | Metadata struct { 35 | Value string `json:"value"` 36 | Type string `json:"type"` 37 | Filename string `json:"filename"` 38 | Function string `json:"function"` 39 | } `json:"metadata"` 40 | NumComments int64 `json:"numComments"` 41 | AssignedTo struct { 42 | Email string `json:"email"` 43 | Type string `json:"type"` 44 | ID string `json:"id"` 45 | Name string `json:"name"` 46 | } `json:"assignedTo"` 47 | IsBookmarked bool `json:"isBookmarked"` 48 | IsSubscribed bool `json:"isSubscribed"` 49 | SubscriptionDetails struct { 50 | Reason string `json:"reason"` 51 | } `json:"subscriptionDetails"` 52 | // StatusDetails struct { 53 | // } `json:"statusDetails"` 54 | // Stats struct { 55 | // Two4H [][]int64 `json:"24h"` 56 | // } `json:"stats"` 57 | // ShareID interface{} `json:"shareId"` 58 | // Logger interface{} `json:"logger"` 59 | // Annotations []interface{} `json:"annotations"` 60 | } 61 | 62 | type GetIssuesInput struct { 63 | OrganizationSlug string 64 | ProjectIds []string 65 | Environments []string 66 | Query string 67 | From time.Time 68 | To time.Time 69 | Sort string 70 | Limit int64 71 | } 72 | 73 | func (gii *GetIssuesInput) ToQuery() string { 74 | urlPath := fmt.Sprintf("/api/0/organizations/%s/issues/?", gii.OrganizationSlug) 75 | if gii.Limit < 1 || gii.Limit > 10000 { 76 | gii.Limit = 10000 77 | } 78 | params := url.Values{} 79 | params.Set("query", gii.Query) 80 | params.Set("start", gii.From.Format("2006-01-02T15:04:05")) 81 | params.Set("end", gii.To.Format("2006-01-02T15:04:05")) 82 | if gii.Sort != "" { 83 | params.Set("sort", gii.Sort) 84 | } 85 | params.Set("limit", strconv.FormatInt(gii.Limit, 10)) 86 | for _, projectId := range gii.ProjectIds { 87 | params.Add("project", projectId) 88 | } 89 | for _, environment := range gii.Environments { 90 | params.Add("environment", environment) 91 | } 92 | return urlPath + params.Encode() 93 | } 94 | 95 | func (sc *SentryClient) GetIssues(gii GetIssuesInput) ([]SentryIssue, string, error) { 96 | out := []SentryIssue{} 97 | executedQueryString := gii.ToQuery() 98 | err := sc.Fetch(executedQueryString, &out) 99 | return out, sc.BaseURL + executedQueryString, err 100 | } 101 | -------------------------------------------------------------------------------- /pkg/sentry/metrics.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type MetricsResponse struct { 11 | Start time.Time `json:"start"` 12 | End time.Time `json:"end"` 13 | Intervals []time.Time `json:"intervals"` 14 | Groups []struct { 15 | By map[string]interface{} `json:"by"` 16 | Totals map[string]interface{} `json:"totals"` 17 | Series map[string]interface{} `json:"series"` 18 | } `json:"groups"` 19 | } 20 | 21 | type GetMetricsInput struct { 22 | OrganizationSlug string 23 | ProjectIds []string 24 | Environments []string 25 | From time.Time 26 | To time.Time 27 | Interval time.Duration 28 | Field string 29 | Query string 30 | GroupBy string 31 | Sort string 32 | Order string 33 | Limit int64 34 | } 35 | 36 | func (args *GetMetricsInput) ToQuery() string { 37 | urlPath := fmt.Sprintf("/api/0/organizations/%s/metrics/data/?", args.OrganizationSlug) 38 | params := url.Values{} 39 | params.Set("includeSeries", "1") 40 | params.Set("start", args.From.Format("2006-01-02T15:04:05")) 41 | params.Set("end", args.To.Format("2006-01-02T15:04:05")) 42 | params.Set("interval", FormatSentryInterval(args.Interval)) 43 | if args.GroupBy != "" { 44 | params.Add("groupBy", args.GroupBy) 45 | if args.GroupBy != "session.status" { 46 | if args.Limit < 1 || args.Limit > 10 { 47 | args.Limit = 5 48 | } 49 | var orderModifier = "-" 50 | if args.Order == "asc" { 51 | orderModifier = "" 52 | } 53 | if args.Sort != "" { 54 | params.Add("orderBy", orderModifier+args.Sort) 55 | } else { 56 | params.Add("orderBy", orderModifier+args.Field) 57 | } 58 | params.Set("per_page", strconv.FormatInt(args.Limit, 10)) 59 | } 60 | } else { 61 | params.Set("per_page", "1") 62 | } 63 | for _, projectId := range args.ProjectIds { 64 | if projectId != "" { 65 | params.Add("project", projectId) 66 | } 67 | } 68 | for _, environment := range args.Environments { 69 | params.Add("environment", environment) 70 | } 71 | params.Add("field", args.Field) 72 | if args.Query != "" { 73 | params.Add("query", args.Query) 74 | } 75 | return urlPath + params.Encode() 76 | } 77 | 78 | func (sc *SentryClient) GetMetrics(args GetMetricsInput) (MetricsResponse, string, error) { 79 | var out MetricsResponse 80 | executedQueryString := args.ToQuery() 81 | err := sc.Fetch(executedQueryString, &out) 82 | return out, sc.BaseURL + executedQueryString, err 83 | } 84 | -------------------------------------------------------------------------------- /pkg/sentry/orgs.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type SentryOrganization struct { 8 | DateCreated time.Time `json:"dateCreated"` 9 | ID string `json:"id"` 10 | IsEarlyAdopter bool `json:"isEarlyAdopter"` 11 | Name string `json:"name"` 12 | Require2FA bool `json:"require2FA"` 13 | Slug string `json:"slug"` 14 | Status struct { 15 | ID string `json:"id"` 16 | Name string `json:"name"` 17 | } `json:"status"` 18 | Avatar struct { 19 | AvatarType string `json:"avatarType"` 20 | } `json:"avatar"` 21 | } 22 | 23 | func (sc *SentryClient) GetOrganizations() ([]SentryOrganization, error) { 24 | out := []SentryOrganization{} 25 | err := sc.Fetch("/api/0/organizations/", &out) 26 | return out, err 27 | } 28 | -------------------------------------------------------------------------------- /pkg/sentry/projects.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | ) 7 | 8 | type SentryProject struct { 9 | DateCreated time.Time `json:"dateCreated"` 10 | HasAccess bool `json:"hasAccess"` 11 | ID string `json:"id"` 12 | IsBookmarked bool `json:"isBookmarked"` 13 | IsMember bool `json:"isMember"` 14 | Environments []string `json:"environments"` 15 | Name string `json:"name"` 16 | Slug string `json:"slug"` 17 | Team struct { 18 | ID string `json:"id"` 19 | Name string `json:"name"` 20 | Slug string `json:"slug"` 21 | } `json:"team"` 22 | Teams []struct { 23 | ID string `json:"id"` 24 | Name string `json:"name"` 25 | Slug string `json:"slug"` 26 | } `json:"teams"` 27 | } 28 | 29 | func (sc *SentryClient) GetProjects(organizationSlug string, withPagination bool) ([]SentryProject, error) { 30 | projects := []SentryProject{} 31 | if organizationSlug == "" { 32 | organizationSlug = sc.OrgSlug 33 | } 34 | url := "/api/0/organizations/" + organizationSlug + "/projects/" 35 | 36 | if (withPagination) { 37 | for (url != "") { 38 | batch := []SentryProject{} 39 | nextURL, err := sc.FetchWithPagination(url, &batch) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | projects = append(projects, batch...) 45 | url = nextURL 46 | } 47 | return projects, nil 48 | } else { 49 | err := sc.Fetch(url, &projects) 50 | return projects, err 51 | } 52 | } 53 | 54 | func (sc *SentryClient) GetTeamsProjects(organizationSlug string, teamSlug string) ([]SentryProject, error) { 55 | out := []SentryProject{} 56 | if organizationSlug == "" { 57 | organizationSlug = sc.OrgSlug 58 | } 59 | if teamSlug == "" { 60 | return out, errors.New("invalid team slug") 61 | } 62 | err := sc.Fetch("/api/0/teams/"+organizationSlug+"/"+teamSlug+"/projects/", &out) 63 | return out, err 64 | } 65 | -------------------------------------------------------------------------------- /pkg/sentry/sentry.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/build" 12 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 13 | "github.com/peterhellberg/link" 14 | ) 15 | 16 | type SentryClient struct { 17 | BaseURL string 18 | OrgSlug string 19 | authToken string 20 | sentryHttpClient HTTPClient 21 | } 22 | 23 | func NewSentryClient(baseURL string, orgSlug string, authToken string, doerClient doer) (*SentryClient, error) { 24 | client := &SentryClient{ 25 | BaseURL: DefaultSentryURL, 26 | OrgSlug: orgSlug, 27 | authToken: authToken, 28 | } 29 | if baseURL != "" { 30 | client.BaseURL = baseURL 31 | } 32 | client.sentryHttpClient = NewHTTPClient(doerClient, PluginID, build.GetBuildInfo, client.authToken) 33 | return client, nil 34 | } 35 | 36 | type SentryErrorResponse struct { 37 | Detail string `json:"detail"` 38 | } 39 | 40 | func closeHttpResponseBody(res *http.Response) { 41 | if err := res.Body.Close(); err != nil { 42 | backend.Logger.Warn("Error closing http response", "error", err.Error()) 43 | } 44 | } 45 | 46 | func (sc *SentryClient) FetchWithPagination(path string, out interface{}) (string, error) { 47 | fullURL := path 48 | if !strings.HasPrefix(path, sc.BaseURL) { 49 | fullURL = sc.BaseURL + path 50 | } 51 | req, err := http.NewRequest(http.MethodGet, fullURL, nil) 52 | if err != nil { 53 | return "", err 54 | } 55 | res, err := sc.sentryHttpClient.Do(req) 56 | if err != nil { 57 | return "", err 58 | } 59 | defer closeHttpResponseBody(res) 60 | 61 | nextURL := "" 62 | header := res.Header 63 | links := link.ParseHeader(header) 64 | 65 | if links != nil { 66 | if nextLink, found := links["next"]; found && nextLink.Extra["results"] == "true" { 67 | nextURL = nextLink.URI 68 | } 69 | } 70 | 71 | if res.StatusCode == http.StatusOK { 72 | if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 73 | return "", err 74 | } 75 | } else { 76 | var errResponse SentryErrorResponse 77 | if err := json.NewDecoder(res.Body).Decode(&errResponse); err != nil { 78 | errorMessage := strings.TrimSpace(fmt.Sprintf("%s %s", res.Status, err.Error())) 79 | return "", errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), errors.New(errorMessage), false) 80 | } 81 | errorMessage := strings.TrimSpace(fmt.Sprintf("%s %s", res.Status, errResponse.Detail)) 82 | return "", errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), errors.New(errorMessage), false) 83 | } 84 | return nextURL, nil 85 | } 86 | 87 | func (sc *SentryClient) Fetch(path string, out interface{}) error { 88 | req, err := http.NewRequest(http.MethodGet, sc.BaseURL+path, nil) 89 | if err != nil { 90 | return err 91 | } 92 | res, err := sc.sentryHttpClient.Do(req) 93 | if err != nil { 94 | return err 95 | } 96 | defer closeHttpResponseBody(res) 97 | 98 | if res.StatusCode == http.StatusOK { 99 | if err := json.NewDecoder(res.Body).Decode(&out); err != nil { 100 | return err 101 | } 102 | } else { 103 | var errResponse SentryErrorResponse 104 | if err := json.NewDecoder(res.Body).Decode(&errResponse); err != nil { 105 | errorMessage := strings.TrimSpace(fmt.Sprintf("%s %s", res.Status, err.Error())) 106 | return errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), errors.New(errorMessage), false) 107 | } 108 | errorMessage := strings.TrimSpace(fmt.Sprintf("%s %s", res.Status, errResponse.Detail)) 109 | return errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(res.StatusCode), errors.New(errorMessage), false) 110 | } 111 | return err 112 | } 113 | -------------------------------------------------------------------------------- /pkg/sentry/stats_v2.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "time" 9 | ) 10 | 11 | type StatsV2Response struct { 12 | Start time.Time `json:"start"` 13 | End time.Time `json:"end"` 14 | Intervals []time.Time `json:"intervals"` 15 | Groups []struct { 16 | By struct { 17 | Reason string `json:"reason"` 18 | Category string `json:"category"` 19 | Outcome string `json:"outcome"` 20 | } `json:"by"` 21 | Totals struct { 22 | SumTimesSeen int64 `json:"sum(times_seen)"` 23 | SumQuantity int64 `json:"sum(quantity)"` 24 | } `json:"totals"` 25 | Series struct { 26 | SumTimesSeen []int64 `json:"sum(times_seen)"` 27 | SumQuantity []int64 `json:"sum(quantity)"` 28 | } `json:"series"` 29 | } `json:"groups"` 30 | } 31 | 32 | type GetStatsV2Input struct { 33 | OrganizationSlug string 34 | From time.Time 35 | To time.Time 36 | Interval string 37 | Category []string 38 | Fields []string 39 | GroupBy []string 40 | ProjectIds []string 41 | Outcome []string 42 | Reason []string 43 | } 44 | 45 | func (args *GetStatsV2Input) ToQuery() string { 46 | urlPath := fmt.Sprintf("/api/0/organizations/%s/stats_v2/?", args.OrganizationSlug) 47 | params := url.Values{} 48 | params.Set("start", args.From.Format("2006-01-02T15:04:05")) 49 | params.Set("end", args.To.Format("2006-01-02T15:04:05")) 50 | if args.Interval != "" { 51 | params.Add("interval", args.Interval) 52 | } 53 | for _, category := range args.Category { 54 | if category != "" { 55 | params.Add("category", category) 56 | } 57 | } 58 | for _, field := range args.Fields { 59 | if field != "" { 60 | params.Add("field", field) 61 | } 62 | } 63 | for _, groupBy := range args.GroupBy { 64 | if groupBy != "" { 65 | params.Add("groupBy", groupBy) 66 | } 67 | } 68 | for _, projectId := range args.ProjectIds { 69 | if projectId != "" { 70 | params.Add("project", projectId) 71 | } 72 | } 73 | for _, outcome := range args.Outcome { 74 | if outcome != "" { 75 | params.Add("outcome", outcome) 76 | } 77 | } 78 | for _, reason := range args.Reason { 79 | if reason != "" { 80 | params.Add("reason", reason) 81 | } 82 | } 83 | return urlPath + params.Encode() 84 | } 85 | 86 | func (sc *SentryClient) GetStatsV2(args GetStatsV2Input) (StatsV2Response, string, error) { 87 | var out StatsV2Response 88 | if len(args.Fields) < 1 { 89 | return out, "", errors.New(`at least one "field" is required`) 90 | } 91 | if len(args.Category) < 1 { 92 | return out, "", errors.New(`at least one "category" is required`) 93 | } 94 | if args.Interval != "" && !regexp.MustCompile(`^\d+[mhdw]$`).MatchString(args.Interval) { 95 | return out, "", errors.New(`"interval" should be in the format [number][unit] where unit is one of m/h/d/w`) 96 | } 97 | executedQueryString := args.ToQuery() 98 | err := sc.Fetch(executedQueryString, &out) 99 | return out, sc.BaseURL + executedQueryString, err 100 | } 101 | -------------------------------------------------------------------------------- /pkg/sentry/tags.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | type SentryTag struct { 4 | Key string `json:"key"` 5 | Name string `json:"name"` 6 | TotalValues int `json:"totalValues"` 7 | } 8 | 9 | func (sc *SentryClient) GetTags(organizationSlug string, withPagination bool) ([]SentryTag, error) { 10 | tags := []SentryTag{} 11 | if organizationSlug == "" { 12 | organizationSlug = sc.OrgSlug 13 | } 14 | url := "/api/0/organizations/" + organizationSlug + "/tags/" 15 | 16 | if withPagination { 17 | for url != "" { 18 | batch := []SentryTag{} 19 | nextURL, err := sc.FetchWithPagination(url, &batch) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | tags = append(tags, batch...) 25 | url = nextURL 26 | } 27 | return tags, nil 28 | } else { 29 | err := sc.Fetch(url, &tags) 30 | return tags, err 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/sentry/team.go: -------------------------------------------------------------------------------- 1 | package sentry 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type SentryTeam struct { 9 | Avatar struct { 10 | AvatarType string `json:"avatarType"` 11 | AvatarUUID interface{} `json:"avatarUuid"` 12 | } `json:"avatar"` 13 | DateCreated time.Time `json:"dateCreated"` 14 | HasAccess bool `json:"hasAccess"` 15 | ID string `json:"id"` 16 | IsMember bool `json:"isMember"` 17 | IsPending bool `json:"isPending"` 18 | MemberCount int `json:"memberCount"` 19 | Name string `json:"name"` 20 | Projects []interface{} `json:"projects"` 21 | Slug string `json:"slug"` 22 | } 23 | 24 | func (sc *SentryClient) ListOrganizationTeams(organizationSlug string, withPagination bool) ([]SentryTeam, error) { 25 | teams := []SentryTeam{} 26 | url := fmt.Sprintf("/api/0/organizations/%s/teams/", organizationSlug) 27 | 28 | if withPagination { 29 | for url != "" { 30 | batch := []SentryTeam{} 31 | nextURL, err := sc.FetchWithPagination(url, &batch) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | teams = append(teams, batch...) 37 | url = nextURL 38 | } 39 | return teams, nil 40 | } else { 41 | err := sc.Fetch(url, &teams) 42 | return teams, err 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /pkg/util/constants.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | const ( 4 | SuccessfulHealthCheckMessage string = "plugin health check successful" 5 | DefaultSentryURL string = "https://sentry.io" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/util/fakeclient.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/grafana/sentry-datasource/pkg/sentry" 10 | ) 11 | 12 | const fakeSentryUrl string = "https://foo.com" 13 | const fakeSentryOrg string = "foo" 14 | const fakeSentryAuthToken string = "fake-token" 15 | const fakeResponseBody string = "{}" 16 | 17 | type FakeDoer struct { 18 | Body string 19 | AuthToken string 20 | ExpectedError error 21 | ExpectedStatusCode int 22 | ExpectedStatus string 23 | } 24 | 25 | func (fd *FakeDoer) Do(req *http.Request) (*http.Response, error) { 26 | res := &http.Response{ 27 | StatusCode: http.StatusOK, 28 | Status: "200 OK", 29 | Body: io.NopCloser(bytes.NewBufferString(fakeResponseBody)), 30 | } 31 | if fd.AuthToken != "" && fd.AuthToken != fakeSentryAuthToken { 32 | res.StatusCode = 401 33 | res.Status = "401 Unauthorized" 34 | return res, nil 35 | } 36 | if fd.ExpectedStatusCode > 0 { 37 | res.StatusCode = fd.ExpectedStatusCode 38 | } 39 | if fd.ExpectedStatus != "" { 40 | res.Status = fd.ExpectedStatus 41 | } 42 | if fd.ExpectedError != nil { 43 | return nil, fd.ExpectedError 44 | } 45 | if fd.Body != "" { 46 | res.Body = io.NopCloser(bytes.NewBufferString(fd.Body)) 47 | } 48 | if res.Body != nil { 49 | return res, nil 50 | } 51 | return nil, errors.New("fake client not implemented") 52 | } 53 | 54 | func NewFakeClient(props FakeDoer) *sentry.SentryClient { 55 | sc, _ := sentry.NewSentryClient(fakeSentryUrl, fakeSentryOrg, fakeSentryAuthToken, &props) 56 | return sc 57 | } 58 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { defineConfig, devices } from '@playwright/test'; 3 | import type { PluginOptions } from '@grafana/plugin-e2e'; 4 | 5 | const pluginE2eAuth = `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`; 6 | 7 | export default defineConfig({ 8 | testDir: './e2e', 9 | /* Run tests in files in parallel */ 10 | fullyParallel: true, 11 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 12 | forbidOnly: !!process.env.CI, 13 | /* Retry on CI only */ 14 | retries: process.env.CI ? 2 : 0, 15 | /* Opt out of parallel tests on CI. */ 16 | workers: process.env.CI ? 1 : undefined, 17 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 18 | reporter: 'html', 19 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 20 | use: { 21 | /* Base URL to use in actions like `await page.goto('/')`. */ 22 | baseURL: 'http://localhost:3000', 23 | 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: 'on-first-retry', 26 | video: 'on', 27 | }, 28 | 29 | /* Configure projects for major browsers */ 30 | projects: [ 31 | { 32 | name: 'auth', 33 | testDir: pluginE2eAuth, 34 | testMatch: [/.*\.js/], 35 | }, 36 | { 37 | name: 'run-tests', 38 | use: { 39 | ...devices['Desktop Chrome'], 40 | // @grafana/plugin-e2e writes the auth state to this file, 41 | // the path should not be modified 42 | storageState: 'playwright/.auth/admin.json', 43 | }, 44 | dependencies: ['auth'], 45 | }, 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /provisioning/datasources/sentry.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | deleteDatasources: 4 | - name: Sentry 5 | orgId: 1 6 | 7 | datasources: 8 | - editable: true 9 | enabled: true 10 | uid: SENTRY1234 11 | jsonData: 12 | url: https://sentry.io 13 | orgSlug: $ORG_SLUG 14 | name: Sentry 15 | secureJsonData: 16 | authToken: $AUTH_TOKEN 17 | type: grafana-sentry-datasource 18 | version: 1 19 | -------------------------------------------------------------------------------- /src/app/replace.spec.ts: -------------------------------------------------------------------------------- 1 | import { ScopedVars } from '@grafana/data'; 2 | import * as runtime from '@grafana/runtime'; 3 | import { SentryIssuesQuery, SentryEventsQuery, SentryStatsV2Query } from 'types'; 4 | import { applyTemplateVariables, replaceProjectIDs } from './replace'; 5 | 6 | describe('replace', () => { 7 | afterEach(jest.clearAllMocks); 8 | 9 | describe('replaceProjectIDs', () => { 10 | it('default replaceProjectIDs should return valid objects', () => { 11 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 12 | containsTemplate: jest.fn(), 13 | updateTimeRange: jest.fn(), 14 | getVariables: jest.fn(), 15 | replace: (s: string) => { 16 | return s; 17 | }, 18 | })); 19 | const a = replaceProjectIDs(['hello', 'world']); 20 | expect(a).toStrictEqual(['hello', 'world']); 21 | }); 22 | it('list with variables passed to replaceProjectIDs should return valid objects', () => { 23 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 24 | containsTemplate: jest.fn(), 25 | updateTimeRange: jest.fn(), 26 | getVariables: jest.fn(), 27 | replace: (s: string) => { 28 | return s === '${attr}' ? 'foo' : s; 29 | }, 30 | })); 31 | const a = replaceProjectIDs(['hello', '${attr}', 'world']); 32 | expect(a).toStrictEqual(['hello', 'foo', 'world']); 33 | }); 34 | it('var with multiple value replaceProjectIDs should return valid objects', () => { 35 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 36 | containsTemplate: jest.fn(), 37 | updateTimeRange: jest.fn(), 38 | getVariables: jest.fn(), 39 | replace: (s: string): any => { 40 | return s === '${attr}' ? 'foo,bar' : s; 41 | }, 42 | })); 43 | const a = replaceProjectIDs(['hello', '${attr}', 'world']); 44 | expect(a).toStrictEqual(['hello', 'foo', 'bar', 'world']); 45 | }); 46 | }); 47 | 48 | describe('applyTemplateVariables', () => { 49 | beforeEach(() => { 50 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 51 | containsTemplate: jest.fn(), 52 | updateTimeRange: jest.fn(), 53 | getVariables: jest.fn(), 54 | replace: (s: string, vars: ScopedVars) => { 55 | for (const key in vars) { 56 | s = s.replace('${' + key + '}', vars[key]?.value); 57 | } 58 | return s; 59 | }, 60 | })); 61 | }); 62 | 63 | it('should interpolate template variables for issues', () => { 64 | const query: SentryIssuesQuery = { 65 | refId: '', 66 | queryType: 'issues', 67 | projectIds: ['${foo}', 'baz'], 68 | environments: [], 69 | issuesQuery: 'hello ${foo}', 70 | }; 71 | 72 | const output = applyTemplateVariables(query, { foo: { value: 'bar', text: 'bar' } }) as SentryIssuesQuery; 73 | expect(output.projectIds).toStrictEqual(['bar', 'baz']); 74 | expect(output.issuesQuery).toStrictEqual('hello bar'); 75 | }); 76 | 77 | it('should interpolate template variables for events', () => { 78 | const query: SentryEventsQuery = { 79 | refId: '', 80 | queryType: 'events', 81 | projectIds: ['${foo}', 'baz'], 82 | environments: ['${foo}', 'baz'], 83 | eventsQuery: 'hello ${foo}', 84 | }; 85 | 86 | const output = applyTemplateVariables(query, { foo: { value: 'bar', text: 'bar' } }) as SentryEventsQuery; 87 | expect(output.projectIds).toStrictEqual(['bar', 'baz']); 88 | expect(output.environments).toStrictEqual(['bar', 'baz']); 89 | expect(output.eventsQuery).toStrictEqual('hello bar'); 90 | }); 91 | 92 | it('should interpolate template variables for statsV2', () => { 93 | const query: SentryStatsV2Query = { 94 | refId: '', 95 | queryType: 'statsV2', 96 | projectIds: ['${foo}', 'baz'], 97 | statsCategory: [], 98 | statsFields: [], 99 | statsGroupBy: [], 100 | statsInterval: '', 101 | statsOutcome: [], 102 | statsReason: [], 103 | }; 104 | 105 | const output = applyTemplateVariables(query, { foo: { value: 'bar', text: 'bar' } }) as SentryStatsV2Query; 106 | expect(output.projectIds).toStrictEqual(['bar', 'baz']); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/app/replace.ts: -------------------------------------------------------------------------------- 1 | import flatten from 'lodash/flatten'; 2 | import { getTemplateSrv } from '@grafana/runtime'; 3 | import type { ScopedVars } from '@grafana/data'; 4 | import type { SentryQuery, SentryVariableQuery } from './../types'; 5 | 6 | const interpolateVariable = (query: string, scopedVars?: ScopedVars): string => { 7 | return getTemplateSrv().replace(query, scopedVars); 8 | }; 9 | 10 | const interpolateVariableArray = (queries: string[], scopedVars?: ScopedVars): string[] => { 11 | return flatten( 12 | (queries || []).map((q) => { 13 | return (getTemplateSrv().replace(q, scopedVars, 'csv') || '').split(','); 14 | }) 15 | ); 16 | }; 17 | 18 | export const replaceProjectIDs = interpolateVariableArray; 19 | 20 | export const applyTemplateVariables = (query: SentryQuery, scopedVars: ScopedVars): SentryQuery => { 21 | switch (query.queryType) { 22 | case 'issues': 23 | return { 24 | ...query, 25 | issuesQuery: interpolateVariable(query.issuesQuery || '', scopedVars), 26 | projectIds: interpolateVariableArray(query.projectIds, scopedVars), 27 | environments: interpolateVariableArray(query.environments, scopedVars), 28 | }; 29 | case 'events': 30 | return { 31 | ...query, 32 | eventsQuery: interpolateVariable(query.eventsQuery || '', scopedVars), 33 | projectIds: interpolateVariableArray(query.projectIds, scopedVars), 34 | environments: interpolateVariableArray(query.environments, scopedVars), 35 | }; 36 | case 'eventsStats': 37 | return { 38 | ...query, 39 | eventsStatsQuery: interpolateVariable(query.eventsStatsQuery || '', scopedVars), 40 | projectIds: interpolateVariableArray(query.projectIds, scopedVars), 41 | environments: interpolateVariableArray(query.environments, scopedVars), 42 | }; 43 | case 'metrics': 44 | return { 45 | ...query, 46 | metricsQuery: interpolateVariable(query.metricsQuery || '', scopedVars), 47 | projectIds: interpolateVariableArray(query.projectIds, scopedVars), 48 | environments: interpolateVariableArray(query.environments, scopedVars), 49 | }; 50 | case 'statsV2': 51 | return { 52 | ...query, 53 | projectIds: interpolateVariableArray(query.projectIds, scopedVars), 54 | }; 55 | default: 56 | return query; 57 | } 58 | }; 59 | 60 | export const applyTemplateVariablesToVariableQuery = (query: SentryVariableQuery): SentryVariableQuery => { 61 | switch (query.type) { 62 | case 'projects': 63 | return { 64 | ...query, 65 | }; 66 | case 'environments': 67 | return { 68 | ...query, 69 | projectIds: (query.projectIds || []).map((projectId) => getTemplateSrv().replace(projectId)), 70 | }; 71 | default: 72 | return query; 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /src/app/utils.ts: -------------------------------------------------------------------------------- 1 | import flatten from 'lodash/flatten'; 2 | import uniq from 'lodash/uniq'; 3 | import type { SentryProject } from './../types'; 4 | 5 | export const getEnvironmentNamesFromProject = (allProjects: SentryProject[], selectedProjectIds: string[]): string[] => { 6 | if (selectedProjectIds && selectedProjectIds.length > 0) { 7 | const environments: string[] = flatten(allProjects.filter((p) => selectedProjectIds.includes(p.id)).map((p) => p.environments || [])); 8 | return uniq(environments); 9 | } else { 10 | return uniq(flatten(allProjects.map((p) => p.environments || []))); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InlineFormLabel } from '@grafana/ui'; 3 | 4 | export const Error = (props: { message: string }) => { 5 | const { message } = props; 6 | return ( 7 |
8 | 9 | {message} 10 | 11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/config-editor/AdditionalSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { config } from '@grafana/runtime'; 3 | import { ConfigSection } from '@grafana/plugin-ui'; 4 | import { Field, Switch } from '@grafana/ui'; 5 | 6 | import { Components } from '../../selectors'; 7 | import { isVersionGtOrEq } from 'utils/version'; 8 | import { SentryConfig } from 'types'; 9 | import { Divider } from './Divider'; 10 | 11 | interface AdditionalSettingsProps { 12 | jsonData: SentryConfig; 13 | onOptionChange: (option: Key, value: Value) => void; 14 | } 15 | 16 | export function AdditionalSettings({ jsonData, onOptionChange }: AdditionalSettingsProps) { 17 | return config.secureSocksDSProxyEnabled && isVersionGtOrEq(config.buildInfo.version, '10.0.0') ? ( 18 | <> 19 | 20 | 26 | 30 | onOptionChange('enableSecureSocksProxy', e.currentTarget.checked)} 34 | /> 35 | 36 | 37 | 38 | ) : null; 39 | } 40 | -------------------------------------------------------------------------------- /src/components/config-editor/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Divider as GrafanaDivider, useTheme2 } from '@grafana/ui'; 3 | import { config } from '@grafana/runtime'; 4 | import { isVersionGtOrEq } from '../../utils/version'; 5 | 6 | export function Divider() { 7 | const theme = useTheme2(); 8 | return isVersionGtOrEq(config.buildInfo.version, '10.1.0') ? ( 9 | 10 | ) : ( 11 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/query-editor/EventsEditor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { EventsEditor } from './EventsEditor'; 4 | import type { SentryEventsQuery } from '../../types'; 5 | import { SentryDataSource } from 'datasource'; 6 | import { PluginType } from '@grafana/data'; 7 | jest.mock('datasource'); 8 | jest.mock('@grafana/runtime', () => { 9 | const original = jest.requireActual('@grafana/runtime'); 10 | return { 11 | ...original, 12 | getTemplateSrv: () => ({ 13 | getVariables: () => [], 14 | replace: (s: string) => s, 15 | }), 16 | }; 17 | }); 18 | 19 | describe('EventsEditor', () => { 20 | it('should render without error', () => { 21 | const query = { 22 | queryType: 'events', 23 | projectIds: [], 24 | environments: [], 25 | eventsQuery: '', 26 | refId: 'A', 27 | } as SentryEventsQuery; 28 | const onChange = jest.fn(); 29 | const onRunQuery = jest.fn(); 30 | const sentryDs = new SentryDataSource({ 31 | id: 1, 32 | uid: '1', 33 | jsonData: { url: '', orgSlug: '' }, 34 | type: 'grafana-sentry-datasource', 35 | name: 'Sentry', 36 | access: 'proxy', 37 | readOnly: false, 38 | meta: { 39 | id: '1', 40 | type: PluginType.datasource, 41 | name: 'Sentry', 42 | info: { 43 | version: '1.0.0', 44 | author: { name: 'grafana', url: '' }, 45 | description: '', 46 | links: [], 47 | logos: { large: '', small: '' }, 48 | screenshots: [], 49 | updated: '', 50 | }, 51 | module: '', 52 | baseUrl: '', 53 | }, 54 | }); 55 | sentryDs.getTags = jest.fn(() => Promise.resolve([])); 56 | const result = render( 57 | 58 | ); 59 | expect(result.container.firstChild).not.toBeNull(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/query-editor/EventsStatsEditor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { EventsStatsEditor } from './EventsStatsEditor'; 4 | import type { SentryEventsStatsQuery } from '../../types'; 5 | 6 | describe('EventsStatsEditor', () => { 7 | it('should render without error', () => { 8 | const query = { 9 | queryType: 'eventsStats', 10 | projectIds: [], 11 | environments: [], 12 | eventsStatsQuery: '', 13 | eventsStatsGroups: [], 14 | eventsStatsYAxis: [], 15 | refId: 'A', 16 | } as SentryEventsStatsQuery; 17 | const onChange = jest.fn(); 18 | const onRunQuery = jest.fn(); 19 | const result = render(); 20 | expect(result.container.firstChild).not.toBeNull(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/query-editor/EventsStatsEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input, QueryField, TagsInput } from '@grafana/ui'; 3 | import { selectors } from '../../selectors'; 4 | import type { SentryEventsStatsQuery } from '../../types'; 5 | import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; 6 | 7 | interface EventsStatsEditorProps { 8 | query: SentryEventsStatsQuery; 9 | onChange: (value: SentryEventsStatsQuery) => void; 10 | onRunQuery: () => void; 11 | } 12 | 13 | export const EventsStatsEditor = ({ query, onChange, onRunQuery }: EventsStatsEditorProps) => { 14 | const onEventsStatsQueryChange = (eventsStatsQuery: string) => { 15 | onChange({ ...query, eventsStatsQuery }); 16 | }; 17 | const onEventsStatsYAxisChange = (eventsStatsYAxis: string[]) => { 18 | onChange({ ...query, eventsStatsYAxis }); 19 | onRunQuery(); 20 | }; 21 | const onEventsStatsGroupsChange = (eventsStatsGroups: string[]) => { 22 | onChange({ ...query, eventsStatsGroups }); 23 | onRunQuery(); 24 | }; 25 | const onEventsStatsSortChange = (eventsStatsSort: string) => { 26 | onChange({ ...query, eventsStatsSort }); 27 | onRunQuery(); 28 | }; 29 | const onEventsStatsLimitChange = (eventsStatsLimit?: number) => { 30 | onChange({ ...query, eventsStatsLimit }); 31 | }; 32 | return ( 33 | <> 34 | 35 | 40 | onEventsStatsQueryChange(val)} 43 | onRunQuery={onRunQuery} 44 | placeholder={selectors.components.QueryEditor.EventsStats.Query.placeholder} 45 | portalOrigin="Sentry" 46 | /> 47 | 48 | 49 | 50 | 55 | 61 | 62 | 63 | 64 | 69 | 75 | 76 | 77 | 78 | 79 | 83 | onEventsStatsSortChange(e.currentTarget.value)} 86 | onBlur={onRunQuery} 87 | width={32} 88 | className="inline-element" 89 | placeholder={selectors.components.QueryEditor.EventsStats.Sort.placeholder} 90 | /> 91 | 92 | 96 | onEventsStatsLimitChange(e.currentTarget.valueAsNumber)} 100 | onBlur={onRunQuery} 101 | width={32} 102 | className="inline-element" 103 | placeholder={selectors.components.QueryEditor.EventsStats.Limit.placeholder} 104 | /> 105 | 106 | 107 | 108 | 109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /src/components/query-editor/IssuesEditor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { IssuesEditor } from './IssuesEditor'; 4 | import type { SentryIssuesQuery } from './../../types'; 5 | 6 | describe('IssuesEditor', () => { 7 | it('should render without error', () => { 8 | const query: SentryIssuesQuery = { 9 | queryType: 'issues', 10 | projectIds: [], 11 | environments: [], 12 | issuesQuery: '', 13 | refId: 'A', 14 | }; 15 | const onChange = jest.fn(); 16 | const onRunQuery = jest.fn(); 17 | const result = render(); 18 | expect(result.container.firstChild).not.toBeNull(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/query-editor/IssuesEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input, QueryField, Select } from '@grafana/ui'; 3 | import { selectors } from '../../selectors'; 4 | import { SentryIssueSortOptions } from '../../constants'; 5 | import type { SentryIssueSort, SentryIssuesQuery } from '../../types'; 6 | import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; 7 | 8 | interface IssuesEditorProps { 9 | query: SentryIssuesQuery; 10 | onChange: (value: SentryIssuesQuery) => void; 11 | onRunQuery: () => void; 12 | } 13 | 14 | export const IssuesEditor = ({ query, onChange, onRunQuery }: IssuesEditorProps) => { 15 | const onIssuesQueryChange = (issuesQuery: string) => { 16 | onChange({ ...query, issuesQuery }); 17 | }; 18 | const onIssuesSortChange = (issuesSort: SentryIssueSort) => { 19 | onChange({ ...query, issuesSort: issuesSort || undefined }); 20 | onRunQuery(); 21 | }; 22 | const onIssuesLimitChange = (issuesLimit?: number) => { 23 | onChange({ ...query, issuesLimit: issuesLimit || undefined }); 24 | }; 25 | return ( 26 | <> 27 | 28 | 33 | {/* TODO: Replace input with CodeEditor */} 34 | onIssuesQueryChange(val)} 37 | onRunQuery={onRunQuery} 38 | placeholder={selectors.components.QueryEditor.Issues.Query.placeholder} 39 | portalOrigin="Sentry" 40 | /> 41 | 42 | 43 | 44 | 45 | 49 | onIssuesLimitChange(e.currentTarget.valueAsNumber)} 67 | onBlur={onRunQuery} 68 | width={32} 69 | className="inline-element" 70 | placeholder={selectors.components.QueryEditor.Issues.Limit.placeholder} 71 | /> 72 | 73 | 74 | 75 | 76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/query-editor/MetricsEditor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { MetricsEditor } from './MetricsEditor'; 4 | import type { SentryMetricsQuery } from '../../types'; 5 | 6 | describe('MetricsEditor', () => { 7 | it('should render without error', () => { 8 | const query = { 9 | queryType: 'metrics', 10 | projectIds: [], 11 | environments: [], 12 | metricsQuery: '', 13 | metricsField: 'session.all', 14 | refId: 'A', 15 | } as SentryMetricsQuery; 16 | const onChange = jest.fn(); 17 | const onRunQuery = jest.fn(); 18 | const result = render(); 19 | expect(result.container.firstChild).not.toBeNull(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/query-editor/MetricsEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input, QueryField, Select } from '@grafana/ui'; 3 | import { selectors } from '../../selectors'; 4 | import type { 5 | SentryMetricsQuery, 6 | SentryMetricsQueryField, 7 | SentryMetricsQueryGroupBy, 8 | SentryMetricsQuerySort, 9 | SentryMetricsQueryOrder, 10 | } from '../../types'; 11 | import { EditorField, EditorRow } from '@grafana/plugin-ui'; 12 | import { 13 | SentryMetricsQueryFieldOptions, 14 | SentryMetricsQueryGroupByOptions, 15 | SentryMetricsQuerySortOptions, 16 | SentryMetricsQueryOrderOptions, 17 | } from '../../constants'; 18 | 19 | interface MetricsEditorProps { 20 | query: SentryMetricsQuery; 21 | onChange: (value: SentryMetricsQuery) => void; 22 | onRunQuery: () => void; 23 | } 24 | 25 | export const MetricsEditor = ({ query, onChange, onRunQuery }: MetricsEditorProps) => { 26 | const onMetricsFieldChange = (metricsField: SentryMetricsQueryField) => { 27 | onChange({ ...query, metricsField }); 28 | onRunQuery(); 29 | }; 30 | const onMetricsQueryChange = (metricsQuery: string) => { 31 | onChange({ ...query, metricsQuery }); 32 | }; 33 | const onMetricsGroupByChange = (metricsGroupBy: SentryMetricsQueryGroupBy) => { 34 | onChange({ ...query, metricsGroupBy }); 35 | onRunQuery(); 36 | }; 37 | const onMetricsSortByChange = (metricsSort: SentryMetricsQuerySort) => { 38 | onChange({ ...query, metricsSort }); 39 | onRunQuery(); 40 | }; 41 | const onMetricsSortOrderChange = (metricsOrder: SentryMetricsQueryOrder) => { 42 | onChange({ ...query, metricsOrder }); 43 | onRunQuery(); 44 | }; 45 | const onMetricsLimitChange = (metricsLimit: number) => { 46 | onChange({ ...query, metricsLimit }); 47 | onRunQuery(); 48 | }; 49 | return ( 50 | <> 51 | 52 | 57 | onMetricsGroupByChange(e?.value!)} 91 | className="inline-element" 92 | placeholder={selectors.components.QueryEditor.Metrics.GroupBy.placeholder} 93 | isClearable={true} 94 | /> 95 | 96 | 97 | {query.metricsGroupBy && query.metricsGroupBy !== 'session.status' && ( 98 | 99 | 104 | onMetricsSortOrderChange(e?.value!)} 122 | className="inline-element" 123 | placeholder={selectors.components.QueryEditor.Metrics.Order.placeholder} 124 | isClearable={true} 125 | /> 126 | 127 | 131 | onMetricsLimitChange(e.currentTarget.valueAsNumber)} 135 | onBlur={onRunQuery} 136 | width={32} 137 | className="inline-element" 138 | placeholder={selectors.components.QueryEditor.Metrics.Limit.placeholder} 139 | /> 140 | 141 | 142 | )} 143 | 144 | ); 145 | }; 146 | -------------------------------------------------------------------------------- /src/components/query-editor/QueryTypePicker.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { QueryTypePicker } from './QueryTypePicker'; 4 | import type { SentryQuery } from './../../types'; 5 | 6 | describe('QueryTypePicker', () => { 7 | it('should render without error', () => { 8 | const query = {} as SentryQuery; 9 | const onChange = jest.fn(); 10 | const onRunQuery = jest.fn(); 11 | const result = render(); 12 | expect(result.container.firstChild).not.toBeNull(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/query-editor/QueryTypePicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Select } from '@grafana/ui'; 3 | import { SentryDataSource } from './../../datasource'; 4 | import { selectors } from './../../selectors'; 5 | import { QueryTypeOptions } from '../../constants'; 6 | import type { QueryEditorProps } from '@grafana/data'; 7 | import type { SentryConfig, SentryQuery, QueryType } from './../../types'; 8 | import { EditorField, EditorRow } from '@grafana/plugin-ui'; 9 | 10 | type QueryTypePickerProps = Pick< 11 | QueryEditorProps, 12 | 'query' | 'onChange' | 'onRunQuery' 13 | >; 14 | 15 | export const QueryTypePicker = ({ query, onChange, onRunQuery }: QueryTypePickerProps) => { 16 | const onQueryTypeChange = (queryType?: QueryType) => { 17 | if (queryType) { 18 | onChange({ ...query, queryType } as SentryQuery); 19 | onRunQuery(); 20 | } 21 | }; 22 | return ( 23 | 24 | 28 | 29 | options={QueryTypeOptions} 30 | value={query.queryType} 31 | onChange={(e) => onQueryTypeChange(e.value)} 32 | placeholder={selectors.components.QueryEditor.QueryType.placeholder} 33 | width={30} 34 | > 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/query-editor/ScopePicker.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as runtime from '@grafana/runtime'; 3 | import { render } from '@testing-library/react'; 4 | import { DataSourceInstanceSettings } from '@grafana/data'; 5 | import { ScopePicker } from './ScopePicker'; 6 | import { SentryDataSource } from './../../datasource'; 7 | import type { SentryQuery, SentryConfig } from './../../types'; 8 | 9 | describe('ScopePicker', () => { 10 | beforeEach(() => { 11 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 12 | containsTemplate: jest.fn(), 13 | updateTimeRange: jest.fn(), 14 | getVariables: () => [], 15 | replace: (s: string) => s, 16 | })); 17 | }); 18 | it('should render without error', () => { 19 | const datasource = new SentryDataSource({} as DataSourceInstanceSettings); 20 | const query = {} as SentryQuery; 21 | const onChange = jest.fn(); 22 | const onRunQuery = jest.fn(); 23 | const result = render(); 24 | expect(result.container.firstChild).not.toBeNull(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/query-editor/ScopePicker.tsx: -------------------------------------------------------------------------------- 1 | import type { QueryEditorProps, SelectableValue } from '@grafana/data'; 2 | import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; 3 | import { getTemplateSrv } from '@grafana/runtime'; 4 | import { MultiSelect } from '@grafana/ui'; 5 | import React, { useEffect, useState } from 'react'; 6 | import { replaceProjectIDs } from './../../app/replace'; 7 | import { getEnvironmentNamesFromProject } from './../../app/utils'; 8 | import { SentryDataSource } from './../../datasource'; 9 | import { selectors } from './../../selectors'; 10 | import type { SentryConfig, SentryProject, SentryQuery } from './../../types'; 11 | 12 | type ScopePickerProps = { hideEnvironments?: boolean } & Pick< 13 | QueryEditorProps, 14 | 'datasource' | 'query' | 'onChange' | 'onRunQuery' 15 | >; 16 | 17 | export const ScopePicker = (props: ScopePickerProps) => { 18 | const { query, onChange, onRunQuery, datasource, hideEnvironments = false } = props; 19 | const { projectIds } = query; 20 | const environments = query.queryType === 'statsV2' ? [] : query.environments; 21 | const [projects, setProjects] = useState([]); 22 | const [allEnvironments, setAllEnvironments] = useState([]); 23 | const orgSlug = datasource.getOrgSlug(); 24 | useEffect(() => { 25 | if (orgSlug) { 26 | datasource.getProjects(orgSlug).then(setProjects).catch(console.error); 27 | } 28 | }, [datasource, orgSlug]); 29 | useEffect(() => { 30 | const updatedProjectIDs = replaceProjectIDs(projectIds); 31 | setAllEnvironments(getEnvironmentNamesFromProject(projects, updatedProjectIDs)); 32 | }, [projects, projectIds]); 33 | const getProjectsAsOptions = (): Array> => { 34 | return [ 35 | ...projects.map((o) => { 36 | return { value: o.id, label: o.name }; 37 | }), 38 | ...(getTemplateSrv().getVariables() || []).map((o) => { 39 | return { value: `\${${o.name}}`, label: `var: ${o.label || o.name}` }; 40 | }), 41 | ]; 42 | }; 43 | const getEnvironmentsAsOptions = (): Array> => { 44 | return [ 45 | ...allEnvironments.map((e) => { 46 | return { value: e, label: e }; 47 | }), 48 | ...(getTemplateSrv().getVariables() || []).map((o) => { 49 | return { value: `\${${o.name}}`, label: `var: ${o.label || o.name}` }; 50 | }), 51 | ]; 52 | }; 53 | const onProjectIDsChange = (projectIds: string[] = []) => { 54 | const applicableEnvironments = getEnvironmentNamesFromProject(projects, projectIds); 55 | const filteredEnvironments = (environments || []).filter((e) => applicableEnvironments.includes(e)); 56 | onChange({ ...query, projectIds, environments: projectIds.length > 0 ? filteredEnvironments : [] } as SentryQuery); 57 | onRunQuery(); 58 | }; 59 | const onEnvironmentsChange = (environments: string[] = []) => { 60 | onChange({ ...query, environments } as SentryQuery); 61 | onRunQuery(); 62 | }; 63 | return ( 64 | 65 | 66 | 70 | onProjectIDsChange(projects.map((p) => p.value!))} 74 | options={getProjectsAsOptions()} 75 | placeholder={selectors.components.QueryEditor.Scope.ProjectIDs.placeholder} 76 | /> 77 | 78 | {!hideEnvironments && ( 79 | 83 | onEnvironmentsChange(e.map((ei) => ei.value!))} 87 | options={getEnvironmentsAsOptions()} 88 | placeholder={selectors.components.QueryEditor.Scope.Environments.placeholder} 89 | /> 90 | 91 | )} 92 | 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/components/query-editor/StatsV2Editor.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { StatsV2Editor } from './StatsV2Editor'; 4 | import type { SentryStatsV2Query } from './../../types'; 5 | 6 | describe('StatsV2Editor', () => { 7 | it('should render without error', () => { 8 | const query: SentryStatsV2Query = { 9 | queryType: 'statsV2', 10 | projectIds: [], 11 | statsFields: [], 12 | statsGroupBy: [], 13 | statsInterval: '', 14 | statsCategory: [], 15 | statsOutcome: [], 16 | statsReason: [], 17 | refId: '', 18 | }; 19 | const onChange = jest.fn(); 20 | const onRunQuery = jest.fn(); 21 | const result = render(); 22 | expect(result.container.firstChild).not.toBeNull(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/query-editor/StatsV2Editor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Select, MultiSelect, Input } from '@grafana/ui'; 3 | import { Components } from '../../selectors'; 4 | import { 5 | SentryStatsV2QueryFieldOptions, 6 | SentryStatsV2QueryGroupByOptions, 7 | SentryStatsV2QueryCategoryOptions, 8 | SentryStatsV2QueryOutcomeOptions, 9 | } from '../../constants'; 10 | import type { SentryStatsV2Query } from '../../types'; 11 | import { EditorField, EditorFieldGroup, EditorRow } from '@grafana/plugin-ui'; 12 | 13 | interface StatsV2EditorProps { 14 | query: SentryStatsV2Query; 15 | onChange: (value: SentryStatsV2Query) => void; 16 | onRunQuery: () => void; 17 | } 18 | 19 | export const StatsV2Editor = ({ query, onChange, onRunQuery }: StatsV2EditorProps) => { 20 | const { StatsV2: StatsV2Selectors } = Components.QueryEditor; 21 | 22 | const onPropChange = (prop: T, value: V) => { 23 | onChange({ ...query, [prop]: value }); 24 | onRunQuery(); 25 | }; 26 | return ( 27 | <> 28 | 29 | 30 | 31 | onPropChange('statsCategory', [e.value!])} 43 | placeholder={StatsV2Selectors.Category.placeholder} 44 | /> 45 | 46 | 47 | 48 | 49 | 50 | 51 | 56 | onPropChange( 57 | 'statsOutcome', 58 | e.map((item) => item.value!) 59 | ) 60 | } 61 | /> 62 | 63 | 64 | 68 | onPropChange( 69 | 'statsReason', 70 | (e.currentTarget.value || '').split(',').map((r) => r.trim()) 71 | ) 72 | } 73 | /> 74 | 75 | 76 | 82 | onPropChange( 83 | 'statsGroupBy', 84 | e.map((item) => item.value!) 85 | ) 86 | } 87 | /> 88 | 89 | 90 | onPropChange('statsInterval', e.currentTarget.value || '')} 94 | /> 95 | 96 | 97 | 98 | 99 | ); 100 | }; 101 | -------------------------------------------------------------------------------- /src/components/variable-query-editor/ProjectSelector.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as runtime from '@grafana/runtime'; 3 | import { render, waitFor } from '@testing-library/react'; 4 | import { DataSourceInstanceSettings } from '@grafana/data'; 5 | import { ProjectSelector } from './ProjectSelector'; 6 | import { SentryDataSource } from './../../datasource'; 7 | import type { SentryConfig } from './../../types'; 8 | 9 | describe('ProjectSelector', () => { 10 | beforeEach(() => { 11 | jest.spyOn(runtime, 'getTemplateSrv').mockImplementation(() => ({ 12 | containsTemplate: jest.fn(), 13 | updateTimeRange: jest.fn(), 14 | getVariables: () => [], 15 | replace: (s: string) => s, 16 | })); 17 | }); 18 | it('should render without error', () => { 19 | const datasource = new SentryDataSource({} as DataSourceInstanceSettings); 20 | datasource.getProjects = jest.fn(() => Promise.resolve([])); 21 | const onChange = jest.fn(); 22 | const result = render(); 23 | waitFor(() => { 24 | expect(result.container.firstChild).not.toBeNull(); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/variable-query-editor/ProjectSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Field, MultiSelect } from '@grafana/ui'; 3 | import { getTemplateSrv } from '@grafana/runtime'; 4 | import { SentryDataSource } from '../../datasource'; 5 | import { selectors } from '../../selectors'; 6 | import type { SentryProject } from '../../types'; 7 | 8 | export const ProjectSelector = (props: { 9 | mode: 'slug' | 'id' | 'name'; 10 | datasource: SentryDataSource; 11 | orgSlug: string; 12 | values: string[]; 13 | onValuesChange: (projectIds: string[]) => void; 14 | label?: string; 15 | tooltip?: string; 16 | }) => { 17 | const { datasource, values: projectIds, orgSlug, mode } = props; 18 | const { label, tooltip, id } = selectors.components.VariablesEditor.Project; 19 | const [projects, setProjects] = useState([]); 20 | useEffect(() => { 21 | if (orgSlug) { 22 | datasource.getProjects(orgSlug).then(setProjects).catch(console.error); 23 | } 24 | }, [datasource, orgSlug]); 25 | const getOptions = () => { 26 | const templateVariables = getTemplateSrv() 27 | .getVariables() 28 | .map((v) => { 29 | return { 30 | value: `\${${v.name}}`, 31 | label: `var: \${${v.name}}`, 32 | }; 33 | }); 34 | const projectVariables = projects.map((p) => { 35 | switch (mode) { 36 | case 'id': 37 | return { value: p.id, label: `${p.name} (${p.id})` }; 38 | case 'name': 39 | return { value: p.name, label: p.name }; 40 | case 'slug': 41 | default: 42 | return { value: p.slug, label: p.name }; 43 | } 44 | }); 45 | return [...projectVariables, ...templateVariables]; 46 | }; 47 | const onProjectIdsChange = (projectIds: string[]) => { 48 | props.onValuesChange(projectIds); 49 | }; 50 | return ( 51 | 52 | onProjectIdsChange(e.map((ei) => ei.value!))} 56 | width={25} 57 | /> 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/variable-query-editor/TeamSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Field, Select } from '@grafana/ui'; 3 | import { getTemplateSrv } from '@grafana/runtime'; 4 | import { SentryDataSource } from '../../datasource'; 5 | import { selectors } from '../../selectors'; 6 | import type { SentryTeam } from './../../types'; 7 | 8 | export const TeamSelector = (props: { 9 | datasource: SentryDataSource; 10 | orgSlug: string; 11 | teamSlug: string; 12 | onValuesChange: (teamSlug: string | null) => void; 13 | }) => { 14 | const { datasource, orgSlug, teamSlug, onValuesChange } = props; 15 | const { label, tooltip, id } = selectors.components.VariablesEditor.Team; 16 | const [teams, setTeams] = useState([]); 17 | useEffect(() => { 18 | datasource.getOrgTeams(orgSlug).then(setTeams); 19 | }, [datasource, orgSlug]); 20 | const getOptions = () => { 21 | const templateVariables = getTemplateSrv() 22 | .getVariables() 23 | .map((v) => { 24 | return { 25 | value: `\${${v.name}}`, 26 | label: `var: \${${v.name}}`, 27 | }; 28 | }); 29 | const teamsVariables = (teams || []).map((t) => { 30 | return { 31 | label: t.name, 32 | value: t.slug, 33 | }; 34 | }); 35 | return [...templateVariables, ...teamsVariables]; 36 | }; 37 | return ( 38 | 39 |