-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathdatasource.ts
More file actions
232 lines (202 loc) · 7.7 KB
/
datasource.ts
File metadata and controls
232 lines (202 loc) · 7.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import {
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
rangeUtil,
ScopedVars
} from '@grafana/data';
import { config, getBackendSrv, getTemplateSrv } from '@grafana/runtime';
import { preprocessData } from './preprocessors';
import { LightstepDataSourceOptions, LightstepQuery } from './types';
import { VariableEditor } from 'components/VariableEditor/VariableEditor';
/**
* THE DATASOURCE
*
* This class is the entry point for the plugin, and directly manages executing
* queries against the Lightstep API.
*/
export class DataSource extends DataSourceApi<LightstepQuery, LightstepDataSourceOptions> {
projectName: string;
orgName: string;
url: string;
constructor(instanceSettings: DataSourceInstanceSettings<LightstepDataSourceOptions>) {
super(instanceSettings);
this.projectName = instanceSettings.jsonData.projectName || '';
this.orgName = instanceSettings.jsonData.orgName || '';
this.url = instanceSettings.url || '';
this.variables = new VariableEditor(this.url, this.defaultProjectName());
}
/**
* Framework hook called by Grafana for each panel. Method is responsible for
* fetching data for each of the "targets" (aka queries) in the request
* options.
* @remarks Grafana framework datasource hook
*/
async query(request: DataQueryRequest<LightstepQuery>): Promise<DataQueryResponse> {
try {
const hashedEmail = await hashEmail(config.bootData.user.email);
const projects = this.projects();
// Project name check: Ensure that every query has a projectName defined, and that
// the defined value is in the current datasource's configured set of projects.
// nb: This is a required check when users have setup a datasource template variable
request.targets.forEach((target) => {
if (!projects.includes(target.projectName)) {
target.projectName = this.defaultProjectName();
}
});
// Only make requests for non-empty, non-hidden queries
const visibleTargets = request.targets.filter((query) => query.text && !query.hide);
if (visibleTargets.length === 0) {
return { data: [] };
}
const projectName = visibleTargets[0].projectName;
const notebookURL = createNotebookURL(request, visibleTargets, projectName);
const requests = visibleTargets.map(async (query) => {
const res = await getBackendSrv().post(`${this.url}/projects/${query.projectName}/telemetry/query_timeseries`, {
data: {
attributes: {
query: query.text,
'input-language': query.language,
'oldest-time': request.range.from,
'youngest-time': request.range.to,
// query_timeseries minimum supported output-period is 1 second
'output-period': Math.max(1, rangeUtil.intervalToSeconds(request.interval)),
'template-variables': createRequestVariables(request.scopedVars),
},
analytics: {
anonymized_user: hashedEmail,
grafana_version: config.buildInfo.version,
query_source: 'grafana',
},
},
});
return preprocessData(res, query, notebookURL);
});
return {
data: await Promise.all(requests),
};
} catch (error: any) {
if (error?.data?.errors && error.data.errors.length > 0) {
// Rethrow with a specific error message to display in panel
throw { message: error.data.errors[0] };
}
throw error;
}
}
/**
* Test & verify datasource settings & connection details
* @remarks Grafana framework datasource hook
*/
async testDatasource() {
// Reject if required fields are missing
if (this.orgName === '') {
return { status: 'error', message: 'Organization name is required' };
}
if (this.defaultProjectName() === '') {
return { status: 'error', message: 'Project name is required' };
}
try {
await getBackendSrv().get(`${this.url}/projects/${this.defaultProjectName()}`);
return {
status: 'success',
message: 'Data source is working',
};
} catch (err: any) {
if (err?.status === 403) {
return { status: 'error', message: 'Invalid API key' };
}
if (err?.data?.message) {
return { status: 'error', message: err.data.message };
}
// REF: Unknown errors and HTTP errors can be re-thrown and will be
// handled here: public/app/features/datasources/state/actions.ts
throw err;
}
}
// --------------------------------------------------------
// QUERY EDITOR METHODS
/** Return the set of configured project names for data source */
projects(): string[] {
// nb string replace removes optional whitespace between project names, eg:
// "dev, pre-prod, prod" -> ["dev", "pre-prod", "prod"]
return this.projectName.replace(/\s/g, '').split(',');
}
/** Returns the first configured project name for data source */
defaultProjectName(): string {
return this.projects()[0];
}
}
/**
* Translates Grafana dashboard variables into a set of LS API template
* variables
*/
export function createRequestVariables(scopedVars?: ScopedVars) {
return getTemplateSrv()
.getVariables()
.map((v) => {
if (v.type === 'query' || v.type === 'textbox' || v.type === 'custom' || v.type === 'constant') {
// normalize different variables values formats into request standard
// array of strings
const { value } = v.current;
let values = Array.isArray(value) ? value : [value];
if (values.length === 1 && values[0] === '$__all') {
values = [];
}
// check if there are scopedVars in the request
// that will override the template variable
// nb: Panel options like Repeat will set scopedVars based on the selected template variable
if (scopedVars && scopedVars?.[v.name]) {
const scopedVar = scopedVars[v.name]?.text ?? "";
if (scopedVar !== "") {
values = [scopedVar]
}
}
return {
name: v.name,
values,
};
}
// SKIP adhoc, datasource, system, and interval template variables
return false;
})
.filter(Boolean);
}
/**
* Create an *anonymous* unique id from user email
* @see https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string
*/
async function hashEmail(email: string) {
try {
const msgUint8 = new TextEncoder().encode(email); // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
return hashHex;
} catch (error) {
// Unable to hash email
return '';
}
}
/**
* Produces a URL for programatically creating an LS Notebook entry matching the
* chart
*/
export function createNotebookURL(
request: DataQueryRequest<LightstepQuery>,
visibleTargets: LightstepQuery[],
projectName: string
) {
const queries = visibleTargets.map((target) => getTemplateSrv().replace(target.text, request.scopedVars));
const searchParam = new URLSearchParams({
version: '2',
title: 'Grafana Chart',
start_micros: String(request.range.from.valueOf() * 1000),
end_micros: String(request.range.to.valueOf() * 1000),
source: 'servicenow-cloudobservability-datasource',
});
queries.forEach((query) => {
searchParam.append('query', query);
});
return `https://app.lightstep.com/${projectName}/notebooks?${searchParam.toString()}`;
}