Tempo: Decouple Tempo from Grafana core (#79888)

pull/80594/head^2
Fabrizio 1 year ago committed by GitHub
parent 767029a43d
commit 522519f671
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 110
      .betterer.results
  2. 4
      .eslintrc
  3. 1
      .github/workflows/core-plugins-build-and-release.yml
  4. 2
      .golangci.toml
  5. 1
      package.json
  6. 2
      packages/grafana-schema/src/raw/composable/tempo/dataquery/x/TempoDataQuery_types.gen.ts
  7. 2
      pkg/tests/api/plugins/data/expectedListResp.json
  8. 41
      pkg/tsdb/tempo/standalone/datasource.go
  9. 16
      pkg/tsdb/tempo/standalone/main.go
  10. 2
      public/app/features/explore/TraceView/TraceView.tsx
  11. 2
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx
  12. 2
      public/app/features/inspector/utils/download.ts
  13. 3
      public/app/features/plugins/built_in_plugins.ts
  14. 2
      public/app/features/query/components/QueryGroup.tsx
  15. 2
      public/app/plugins/datasource/tempo/.eslintignore
  16. 1
      public/app/plugins/datasource/tempo/CHANGELOG.md
  17. 6
      public/app/plugins/datasource/tempo/LokiSearch.tsx
  18. 6
      public/app/plugins/datasource/tempo/NativeSearch/NativeSearch.tsx
  19. 6
      public/app/plugins/datasource/tempo/NativeSearch/TagsField/TagsField.tsx
  20. 3
      public/app/plugins/datasource/tempo/QueryField.tsx
  21. 14
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.test.tsx
  22. 6
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/SearchField.tsx
  23. 14
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TagsInput.test.tsx
  24. 10
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.test.tsx
  25. 8
      public/app/plugins/datasource/tempo/SearchTraceQLEditor/TraceQLSearch.tsx
  26. 7
      public/app/plugins/datasource/tempo/ServiceGraphSection.tsx
  27. 2
      public/app/plugins/datasource/tempo/VariableQueryEditor.test.tsx
  28. 3
      public/app/plugins/datasource/tempo/_importedDependencies/README.md
  29. 128
      public/app/plugins/datasource/tempo/_importedDependencies/actions/appNotification.ts
  30. 5
      public/app/plugins/datasource/tempo/_importedDependencies/actions/index.ts
  31. 36
      public/app/plugins/datasource/tempo/_importedDependencies/actions/types/appNotifications.ts
  32. 1
      public/app/plugins/datasource/tempo/_importedDependencies/actions/types/index.ts
  33. 104
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx
  34. 75
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterBuilder.tsx
  35. 82
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx
  36. 54
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx
  37. 68
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx
  38. 13
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx
  39. 27
      public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/OperatorSegment.tsx
  40. 47
      public/app/plugins/datasource/tempo/_importedDependencies/components/ConfigDescriptionLink.tsx
  41. 25
      public/app/plugins/datasource/tempo/_importedDependencies/components/Divider.tsx
  42. 86
      public/app/plugins/datasource/tempo/_importedDependencies/components/NodeGraphSettings.tsx
  43. 112
      public/app/plugins/datasource/tempo/_importedDependencies/components/TraceView/SpanBarSettings.tsx
  44. 46
      public/app/plugins/datasource/tempo/_importedDependencies/core/appNotification.ts
  45. 21
      public/app/plugins/datasource/tempo/_importedDependencies/core/errors.ts
  46. 265
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LanguageProvider.ts
  47. 90
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LokiQueryField.tsx
  48. 65
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/dataquery.gen.ts
  49. 90
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/languageUtils.ts
  50. 28
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/lineParser.ts
  51. 273
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryField.tsx
  52. 13
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryFieldLazy.tsx
  53. 17
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryFieldProps.ts
  54. 27
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryFieldWrapper.tsx
  55. 112
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/getOverrideServices.ts
  56. 97
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts
  57. 22
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/NeverCaseError.ts
  58. 201
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/index.ts
  59. 67
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/situation.ts
  60. 126
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/validation.ts
  61. 260
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/responseUtils.ts
  62. 281
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/syntax.ts
  63. 97
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts
  64. 115
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/QueryOptionGroup.tsx
  65. 37
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/RawQuery.tsx
  66. 54
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/dataquery.gen.ts
  67. 122
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts
  68. 165
      public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts
  69. 47
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/ConfigDescriptionLink.tsx
  70. 76
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/IntervalInput/IntervalInput.test.tsx
  71. 69
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/IntervalInput/IntervalInput.tsx
  72. 28
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/IntervalInput/validation.test.ts
  73. 6
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/IntervalInput/validation.ts
  74. 112
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToLogs/TagMappingInput.tsx
  75. 123
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToLogs/TraceToLogsSettings.test.tsx
  76. 275
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToLogs/TraceToLogsSettings.tsx
  77. 240
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToMetrics/TraceToMetricsSettings.tsx
  78. 53
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToProfiles/TraceToProfilesSettings.test.tsx
  79. 186
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToProfiles/TraceToProfilesSettings.tsx
  80. 8
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/index.d.ts
  81. 12
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/index.ts
  82. 85
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/pyroscope/ProfileTypesCascader.tsx
  83. 44
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/pyroscope/dataquery.gen.ts
  84. 28
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/pyroscope/datasource.ts
  85. 16
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/pyroscope/types.ts
  86. 118
      public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/utils.ts
  87. 27
      public/app/plugins/datasource/tempo/_importedDependencies/store.ts
  88. 15
      public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts
  89. 8
      public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/selectOptionInTest.ts
  90. 8
      public/app/plugins/datasource/tempo/_importedDependencies/types/index.ts
  91. 155
      public/app/plugins/datasource/tempo/_importedDependencies/utils/fetch.ts
  92. 17
      public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx
  93. 2
      public/app/plugins/datasource/tempo/configuration/LokiSearchSettings.tsx
  94. 3
      public/app/plugins/datasource/tempo/configuration/QuerySettings.tsx
  95. 2
      public/app/plugins/datasource/tempo/configuration/ServiceGraphSettings.tsx
  96. 42
      public/app/plugins/datasource/tempo/datasource.test.ts
  97. 27
      public/app/plugins/datasource/tempo/datasource.ts
  98. 9
      public/app/plugins/datasource/tempo/graphTransform.ts
  99. 76
      public/app/plugins/datasource/tempo/package.json
  100. 8
      public/app/plugins/datasource/tempo/plugin.json
  101. Some files were not shown because too many files have changed in this diff Show More

@ -5808,6 +5808,85 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/Divider.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/NodeGraphSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/TraceView/SpanBarSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LanguageProvider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/MonacoQueryField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/monaco-query-field/monaco-completion-provider/index.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/language_utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToLogs/TagMappingInput.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/TraceToMetrics/TraceToMetricsSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/grafana-traces/src/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/store.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/test/helpers/createFetchResponse.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/utils/fetch.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
],
"public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
@ -5844,9 +5923,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
],
"public/app/plugins/datasource/tempo/language_provider.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@ -7318,6 +7399,29 @@ exports[`no gf-form usage`] = {
"public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryCodeEditor.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilter.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterKey.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterRenderer.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/AdHocFilterValue.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/components/AdHocFilter/ConditionSegment.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/tempo/_importedDependencies/datasources/loki/LokiQueryField.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]
],
"public/app/plugins/datasource/zipkin/QueryField.tsx:5381": [
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"],
[0, 0, 0, "gf-form usage has been deprecated. Use a component from @grafana/ui or custom CSS instead.", "5381"]

@ -101,7 +101,9 @@
"public/app/plugins/datasource/azuremonitor/*.{ts,tsx}",
"public/app/plugins/datasource/azuremonitor/**/*.{ts,tsx}",
"public/app/plugins/datasource/parca/*.{ts,tsx}",
"public/app/plugins/datasource/parca/**/*.{ts,tsx}"
"public/app/plugins/datasource/parca/**/*.{ts,tsx}",
"public/app/plugins/datasource/tempo/*.{ts,tsx}",
"public/app/plugins/datasource/tempo/**/*.{ts,tsx}"
],
"settings": {
"import/resolver": {

@ -11,6 +11,7 @@ on:
- grafana-azure-monitor-datasource
- grafana-testdata-datasource
- parca
- tempo
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}-${{ inputs.plugin_id }}

@ -61,6 +61,8 @@ files = [
"**/pkg/tsdb/azuremonitor/**/*",
"**/pkg/tsdb/parca/*",
"**/pkg/tsdb/parca/**/*",
"**/pkg/tsdb/tempo/*",
"**/pkg/tsdb/tempo/**/*",
]
[linters-settings.gocritic]

@ -246,6 +246,7 @@
"@grafana-plugins/grafana-azure-monitor-datasource": "workspace:*",
"@grafana-plugins/grafana-testdata-datasource": "workspace:*",
"@grafana-plugins/parca": "workspace:*",
"@grafana-plugins/tempo": "workspace:*",
"@grafana/aws-sdk": "0.3.1",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",

@ -11,7 +11,7 @@
import * as common from '@grafana/schema';
export const pluginVersion = "10.4.0-pre";
export const pluginVersion = "%VERSION%";
export interface TempoQuery extends common.DataQuery {
filters: Array<TraceqlFilter>;

@ -1581,7 +1581,7 @@
"updated": ""
},
"dependencies": {
"grafanaDependency": "",
"grafanaDependency": "\u003e=10.3.0-0",
"grafanaVersion": "*",
"plugins": []
},

@ -0,0 +1,41 @@
package main
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
tempo "github.com/grafana/grafana/pkg/tsdb/tempo"
)
type Datasource struct {
Service *tempo.Service
}
var (
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.StreamHandler = (*Datasource)(nil)
)
func NewDatasource(c context.Context, b backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return &Datasource{
Service: tempo.ProvideService(httpclient.NewProvider()),
}, nil
}
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
return d.Service.QueryData(ctx, req)
}
func (d *Datasource) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
return d.Service.SubscribeStream(ctx, req)
}
func (d *Datasource) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
return d.Service.PublishStream(ctx, req)
}
func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
return d.Service.RunStream(ctx, req, sender)
}

@ -0,0 +1,16 @@
package main
import (
"os"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func main() {
// Created as described at https://grafana.com/developers/plugin-tools/introduction/backend-plugins
if err := datasource.Manage("tempo", NewDatasource, datasource.ManageOpts{}); err != nil {
log.DefaultLogger.Error(err.Error())
os.Exit(1)
}
}

@ -17,12 +17,12 @@ import {
import { getTemplateSrv } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
import { TempoQuery } from '@grafana-plugins/tempo/types';
import { getTraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { TraceToMetricsData } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings';
import { TraceToProfilesData } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { getTimeZone } from 'app/features/profile/state/selectors';
import { TempoQuery } from 'app/plugins/datasource/tempo/types';
import { useDispatch, useSelector } from 'app/types';
import { changePanelState } from '../state/explorePane';

@ -21,8 +21,8 @@ import { DataFrame, dateTimeFormat, GrafanaTheme2, IconName, LinkModel } from '@
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
import { DataLinkButton, Icon, TextArea, useStyles2 } from '@grafana/ui';
import { RelatedProfilesTitle } from '@grafana-plugins/tempo/resultTransformer';
import { TraceToProfilesOptions } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings';
import { RelatedProfilesTitle } from 'app/plugins/datasource/tempo/resultTransformer';
import { pyroscopeProfileIdTagKey } from '../../../createSpanLink';
import { autoColor } from '../../Theme';

@ -9,9 +9,9 @@ import {
MutableDataFrame,
toCSV,
} from '@grafana/data';
import { transformToOTLP } from '@grafana-plugins/tempo/resultTransformer';
import { transformToJaeger } from '../../../plugins/datasource/jaeger/responseTransform';
import { transformToOTLP } from '../../../plugins/datasource/tempo/resultTransformer';
import { transformToZipkin } from '../../../plugins/datasource/zipkin/utils/transforms';
/**

@ -33,8 +33,7 @@ const cloudMonitoringPlugin = async () =>
await import(/* webpackChunkName: "cloudMonitoringPlugin" */ 'app/plugins/datasource/cloud-monitoring/module');
const azureMonitorPlugin = async () =>
await import(/* webpackChunkName: "azureMonitorPlugin" */ '@grafana-plugins/grafana-azure-monitor-datasource/module');
const tempoPlugin = async () =>
await import(/* webpackChunkName: "tempoPlugin" */ 'app/plugins/datasource/tempo/module');
const tempoPlugin = async () => await import(/* webpackChunkName: "tempoPlugin" */ '@grafana-plugins/tempo/module');
const alertmanagerPlugin = async () =>
await import(/* webpackChunkName: "alertmanagerPlugin" */ 'app/plugins/datasource/alertmanager/module');
const pyroscopePlugin = async () =>

@ -122,7 +122,7 @@ export class QueryGroup extends PureComponent<Props, State> {
defaultDataSource,
});
} catch (error) {
console.log('failed to load data source', error);
console.error('failed to load data source', error);
}
}

@ -0,0 +1,2 @@
# TS generate from cue by cuetsy
**/*.gen.ts

@ -3,10 +3,8 @@ import useAsync from 'react-use/lib/useAsync';
import { InlineLabel } from '@grafana/ui';
import { LokiQueryField } from '../loki/components/LokiQueryField';
import { LokiDatasource } from '../loki/datasource';
import { LokiQuery } from '../loki/types';
import { LokiQueryField } from './_importedDependencies/datasources/loki/LokiQueryField';
import { LokiDatasource, LokiQuery } from './_importedDependencies/datasources/loki/types';
import { TempoQuery } from './types';
import { getDS } from './utils';

@ -4,10 +4,10 @@ import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data';
import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime';
import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { dispatch } from '../_importedDependencies/store';
import { DEFAULT_LIMIT, TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { TempoQuery } from '../types';

@ -4,9 +4,9 @@ import React, { useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../../core/copy/appNotification';
import { notifyApp } from '../../../../../core/reducers/appNotification';
import { dispatch } from '../../../../../store/store';
import { notifyApp } from '../../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../../_importedDependencies/core/appNotification';
import { dispatch } from '../../_importedDependencies/store';
import { TempoDatasource } from '../../datasource';
import { CompletionProvider } from './autocomplete';

@ -15,12 +15,11 @@ import {
withTheme2,
} from '@grafana/ui';
import { LokiQuery } from '../loki/types';
import { LokiSearch } from './LokiSearch';
import NativeSearch from './NativeSearch/NativeSearch';
import TraceQLSearch from './SearchTraceQLEditor/TraceQLSearch';
import { ServiceGraphSection } from './ServiceGraphSection';
import { LokiQuery } from './_importedDependencies/datasources/loki/types';
import { TempoQueryType } from './dataquery.gen';
import { TempoDatasource } from './datasource';
import { QueryEditor } from './traceql/QueryEditor';

@ -1,24 +1,30 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { initTemplateSrv } from 'test/helpers/initTemplateSrv';
import { LanguageProvider } from '@grafana/data';
import { FetchError, setTemplateSrv } from '@grafana/runtime';
import { FetchError } from '@grafana/runtime';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { initTemplateSrv } from '../test_utils';
import { keywordOperators, numberOperators, operators, stringOperators } from '../traceql/traceql';
import SearchField from './SearchField';
describe('SearchField', () => {
let templateSrv = initTemplateSrv('key', [{ name: 'templateVariable1' }, { name: 'templateVariable2' }]);
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
setTemplateSrv(templateSrv);
const expectedValues = {
interpolationVar: 'interpolationText',
interpolationText: 'interpolationText',
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
scopedInterpolationText: 'scopedInterpolationText',
};
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833

@ -8,9 +8,9 @@ import { AccessoryButton } from '@grafana/experimental';
import { FetchError, getTemplateSrv, isFetchError } from '@grafana/runtime';
import { Select, HorizontalGroup, useStyles2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { dispatch } from '../_importedDependencies/store';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { operators as allOperators, stringOperators, numberOperators, keywordOperators } from '../traceql/traceql';

@ -1,24 +1,30 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { initTemplateSrv } from 'test/helpers/initTemplateSrv';
import { FetchError, setTemplateSrv } from '@grafana/runtime';
import { FetchError } from '@grafana/runtime';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { initTemplateSrv } from '../test_utils';
import { Scope } from '../types';
import TagsInput from './TagsInput';
import { v1Tags, v2Tags } from './utils.test';
describe('TagsInput', () => {
let templateSrv = initTemplateSrv('key', [{ name: 'templateVariable1' }, { name: 'templateVariable2' }]);
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
setTemplateSrv(templateSrv);
const expectedValues = {
interpolationVar: 'interpolationText',
interpolationText: 'interpolationText',
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
scopedInterpolationText: 'scopedInterpolationText',
};
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
jest.useFakeTimers();
// Need to use delay: null here to work with fakeTimers
// see https://github.com/testing-library/user-event/issues/833

@ -2,13 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { act } from 'react-dom/test-utils';
import { initTemplateSrv } from 'test/helpers/initTemplateSrv';
import { config } from '@grafana/runtime';
import { TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import TempoLanguageProvider from '../language_provider';
import { initTemplateSrv } from '../test_utils';
import { TempoQuery } from '../types';
import TraceQLSearch from './TraceQLSearch';
@ -43,7 +43,13 @@ jest.mock('../language_provider', () => {
});
describe('TraceQLSearch', () => {
initTemplateSrv('key', []);
const expectedValues = {
interpolationVar: 'interpolationText',
interpolationText: 'interpolationText',
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
scopedInterpolationText: 'scopedInterpolationText',
};
initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
let user: ReturnType<typeof userEvent.setup>;

@ -5,10 +5,10 @@ import { CoreApp, GrafanaTheme2 } from '@grafana/data';
import { config, FetchError, getTemplateSrv, reportInteraction } from '@grafana/runtime';
import { Alert, Button, HorizontalGroup, Select, useStyles2 } from '@grafana/ui';
import { createErrorNotification } from '../../../../core/copy/appNotification';
import { notifyApp } from '../../../../core/reducers/appNotification';
import { dispatch } from '../../../../store/store';
import { RawQuery } from '../../prometheus/querybuilder/shared/RawQuery';
import { notifyApp } from '../_importedDependencies/actions/appNotification';
import { createErrorNotification } from '../_importedDependencies/core/appNotification';
import { RawQuery } from '../_importedDependencies/datasources/prometheus/RawQuery';
import { dispatch } from '../_importedDependencies/store';
import { TraceqlFilter, TraceqlSearchScope } from '../dataquery.gen';
import { TempoDatasource } from '../datasource';
import { TempoQueryBuilderOptions } from '../traceql/TempoQueryBuilderOptions';

@ -5,10 +5,9 @@ import useAsync from 'react-use/lib/useAsync';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { AdHocFilter } from '../../../features/variables/adhoc/picker/AdHocFilter';
import { AdHocVariableFilter } from '../../../features/variables/types';
import { PrometheusDatasource } from '../prometheus/datasource';
import { AdHocFilter } from './_importedDependencies/components/AdHocFilter/AdHocFilter';
import { PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
import { AdHocVariableFilter } from './_importedDependencies/types';
import { TempoQuery } from './types';
import { getDS } from './utils';

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { TemplateSrv } from '@grafana/runtime';
@ -11,6 +10,7 @@ import {
TempoVariableQueryEditorProps,
TempoVariableQueryType,
} from './VariableQueryEditor';
import { selectOptionInTest } from './_importedDependencies/test/helpers/selectOptionInTest';
import { createTempoDatasource } from './mocks';
const refId = 'TempoDatasourceVariableQueryEditor-VariableQuery';

@ -0,0 +1,3 @@
This directory contains dependencies that we duplicated from Grafana core while working on the decoupling of Tempo from such core.
The long-term goal is to move these files away from here by replacing them with packages.
As such, they are only temporary and meant to be used internally to this package, please avoid using them for example as dependencies (imports) in other data source plugins.

@ -0,0 +1,128 @@
import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppNotification, AppNotificationSeverity, AppNotificationsState } from './types/appNotifications';
const MAX_STORED_NOTIFICATIONS = 25;
export const STORAGE_KEY = 'notifications';
export const NEW_NOTIFS_KEY = `${STORAGE_KEY}/lastRead`;
type StoredNotification = Omit<AppNotification, 'component'>;
export const initialState: AppNotificationsState = {
byId: deserializeNotifications(),
lastRead: Number.parseInt(window.localStorage.getItem(NEW_NOTIFS_KEY) ?? `${Date.now()}`, 10),
};
/**
* Reducer and action to show toast notifications of various types (success, warnings, errors etc). Use to show
* transient info to user, like errors that cannot be otherwise handled or success after an action.
*
* Use factory functions in core/copy/appNotifications to create the payload.
*/
const appNotificationsSlice = createSlice({
name: 'appNotifications',
initialState,
reducers: {
notifyApp: (state, { payload: newAlert }: PayloadAction<AppNotification>) => {
if (Object.values(state.byId).some((alert) => isSimilar(newAlert, alert) && alert.showing)) {
return;
}
state.byId[newAlert.id] = newAlert;
serializeNotifications(state.byId);
},
hideAppNotification: (state, { payload: alertId }: PayloadAction<string>) => {
if (!(alertId in state.byId)) {
return;
}
state.byId[alertId].showing = false;
serializeNotifications(state.byId);
},
clearNotification: (state, { payload: alertId }: PayloadAction<string>) => {
delete state.byId[alertId];
serializeNotifications(state.byId);
},
clearAllNotifications: (state) => {
state.byId = {};
serializeNotifications(state.byId);
},
readAllNotifications: (state, { payload: timestamp }: PayloadAction<number>) => {
state.lastRead = timestamp;
},
},
});
export const { notifyApp, hideAppNotification, clearNotification, clearAllNotifications, readAllNotifications } =
appNotificationsSlice.actions;
export const appNotificationsReducer = appNotificationsSlice.reducer;
// Selectors
export const selectLastReadTimestamp = (state: AppNotificationsState) => state.lastRead;
export const selectById = (state: AppNotificationsState) => state.byId;
export const selectAll = createSelector(selectById, (byId) =>
Object.values(byId).sort((a, b) => b.timestamp - a.timestamp)
);
export const selectWarningsAndErrors = createSelector(selectAll, (all) => all.filter(isAtLeastWarning));
export const selectVisible = createSelector(selectById, (byId) => Object.values(byId).filter((n) => n.showing));
// Helper functions
function isSimilar(a: AppNotification, b: AppNotification): boolean {
return a.icon === b.icon && a.severity === b.severity && a.text === b.text && a.title === b.title;
}
function isAtLeastWarning(notif: AppNotification) {
return notif.severity === AppNotificationSeverity.Warning || notif.severity === AppNotificationSeverity.Error;
}
function isStoredNotification(obj: unknown): obj is StoredNotification {
return typeof obj === 'object' && obj !== null && 'id' in obj && 'icon' in obj && 'title' in obj && 'text' in obj;
}
// (De)serialization
export function deserializeNotifications(): Record<string, StoredNotification> {
const storedNotifsRaw = window.localStorage.getItem(STORAGE_KEY);
if (!storedNotifsRaw) {
return {};
}
const parsed = JSON.parse(storedNotifsRaw);
if (!Object.values(parsed).every((v) => isStoredNotification(v))) {
return {};
}
return parsed;
}
function serializeNotifications(notifs: Record<string, StoredNotification>) {
const reducedNotifs = Object.values(notifs)
.filter(isAtLeastWarning)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, MAX_STORED_NOTIFICATIONS)
.reduce<Record<string, StoredNotification>>((prev, cur) => {
prev[cur.id] = {
id: cur.id,
severity: cur.severity,
icon: cur.icon,
title: cur.title,
text: cur.text,
traceId: cur.traceId,
timestamp: cur.timestamp,
// we don't care about still showing toasts after refreshing
// https://github.com/grafana/grafana/issues/71932
showing: false,
};
return prev;
}, {});
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(reducedNotifs));
} catch (err) {
console.error('Unable to persist notifications to local storage');
console.error(err);
}
}

@ -0,0 +1,5 @@
import { appNotificationsReducer as appNotifications } from './appNotification';
export default {
appNotifications,
};

@ -0,0 +1,36 @@
export interface AppNotification {
id: string;
severity: AppNotificationSeverity;
icon: string;
title: string;
text: string;
traceId?: string;
component?: React.ReactElement;
showing: boolean;
timestamp: number;
}
export enum AppNotificationSeverity {
Success = 'success',
Warning = 'warning',
Error = 'error',
Info = 'info',
}
export enum AppNotificationTimeout {
Success = 3000,
Warning = 5000,
Error = 7000,
}
export const timeoutMap = {
[AppNotificationSeverity.Success]: AppNotificationTimeout.Success,
[AppNotificationSeverity.Warning]: AppNotificationTimeout.Warning,
[AppNotificationSeverity.Error]: AppNotificationTimeout.Error,
[AppNotificationSeverity.Info]: AppNotificationTimeout.Success,
};
export interface AppNotificationsState {
byId: Record<string, AppNotification>;
lastRead: number;
}

@ -0,0 +1,104 @@
import React, { PureComponent, ReactNode } from 'react';
import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data';
import { Segment } from '@grafana/ui';
import { AdHocFilterBuilder } from './AdHocFilterBuilder';
import { REMOVE_FILTER_KEY } from './AdHocFilterKey';
import { AdHocFilterRenderer } from './AdHocFilterRenderer';
import { ConditionSegment } from './ConditionSegment';
interface Props {
datasource: DataSourceRef | null;
filters: AdHocVariableFilter[];
baseFilters?: AdHocVariableFilter[];
addFilter: (filter: AdHocVariableFilter) => void;
removeFilter: (index: number) => void;
changeFilter: (index: number, newFilter: AdHocVariableFilter) => void;
disabled?: boolean;
}
/**
* Simple filtering component that automatically uses datasource APIs to get available labels and its values, for
* dynamic visual filtering without need for much setup. Instead of having single onChange prop this reports all the
* change events with separate props so it is usable with AdHocPicker.
*
* Note: There isn't API on datasource to suggest the operators here so that is hardcoded to use prometheus style
* operators. Also filters are assumed to be joined with `AND` operator, which is also hardcoded.
*/
export class AdHocFilter extends PureComponent<Props> {
onChange = (index: number, prop: string) => (key: SelectableValue<string | null>) => {
const { filters } = this.props;
const { value } = key;
if (key.value === REMOVE_FILTER_KEY) {
return this.props.removeFilter(index);
}
return this.props.changeFilter(index, {
...filters[index],
[prop]: value,
});
};
appendFilterToVariable = (filter: AdHocVariableFilter) => {
this.props.addFilter(filter);
};
render() {
const { filters, disabled } = this.props;
return (
<div className="gf-form-inline">
{this.renderFilters(filters, disabled)}
{!disabled && (
<AdHocFilterBuilder
datasource={this.props.datasource!}
appendBefore={filters.length > 0 ? <ConditionSegment label="AND" /> : null}
onCompleted={this.appendFilterToVariable}
allFilters={this.getAllFilters()}
/>
)}
</div>
);
}
getAllFilters() {
if (this.props.baseFilters) {
return this.props.baseFilters.concat(this.props.filters);
}
return this.props.filters;
}
renderFilters(filters: AdHocVariableFilter[], disabled?: boolean) {
if (filters.length === 0 && disabled) {
return <Segment disabled={disabled} value="No filters" options={[]} onChange={() => {}} />;
}
return filters.reduce((segments: ReactNode[], filter, index) => {
if (segments.length > 0) {
segments.push(<ConditionSegment label="AND" key={`condition-${index}`} />);
}
segments.push(this.renderFilterSegments(filter, index, disabled));
return segments;
}, []);
}
renderFilterSegments(filter: AdHocVariableFilter, index: number, disabled?: boolean) {
return (
<React.Fragment key={`filter-${index}`}>
<AdHocFilterRenderer
disabled={disabled}
datasource={this.props.datasource!}
filter={filter}
onKeyChange={this.onChange(index, 'key')}
onOperatorChange={this.onChange(index, 'operator')}
onValueChange={this.onChange(index, 'value')}
allFilters={this.getAllFilters()}
/>
</React.Fragment>
);
}
}

@ -0,0 +1,75 @@
import i18n from 'i18next';
import React, { useCallback, useState } from 'react';
import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data';
import { AdHocFilterKey, REMOVE_FILTER_KEY } from './AdHocFilterKey';
import { AdHocFilterRenderer } from './AdHocFilterRenderer';
interface Props {
datasource: DataSourceRef;
onCompleted: (filter: AdHocVariableFilter) => void;
appendBefore?: React.ReactNode;
allFilters: AdHocVariableFilter[];
}
// Reassign t() so i18next-parser doesn't warn on dynamic key, and we can have 'failOnWarnings' enabled
const tFunc = i18n.t;
// import { t } from 'app/core/internationalization';
export const t = (id: string, defaultMessage: string, values?: Record<string, unknown>) => {
return tFunc(id, defaultMessage, values);
};
export const AdHocFilterBuilder = ({ datasource, appendBefore, onCompleted, allFilters }: Props) => {
const [key, setKey] = useState<string | null>(null);
const [operator, setOperator] = useState<string>('=');
const onKeyChanged = useCallback(
(item: SelectableValue<string | null>) => {
if (item.value !== REMOVE_FILTER_KEY) {
setKey(item.value ?? '');
return;
}
setKey(null);
},
[setKey]
);
const onOperatorChanged = useCallback(
(item: SelectableValue<string>) => setOperator(item.value ?? ''),
[setOperator]
);
const onValueChanged = useCallback(
(item: SelectableValue<string>) => {
onCompleted({
value: item.value ?? '',
operator: operator,
key: key!,
});
setKey(null);
setOperator('=');
},
[onCompleted, operator, key]
);
if (key === null) {
return <AdHocFilterKey datasource={datasource} filterKey={key} onChange={onKeyChanged} allFilters={allFilters} />;
}
return (
<React.Fragment key="filter-builder">
{appendBefore}
<AdHocFilterRenderer
datasource={datasource}
filter={{ key, value: '', operator }}
placeHolder={t('variable.adhoc.placeholder', 'Select value')}
onKeyChange={onKeyChanged}
onOperatorChange={onOperatorChanged}
onValueChange={onValueChanged}
allFilters={allFilters}
/>
</React.Fragment>
);
};

@ -0,0 +1,82 @@
import React, { ReactElement } from 'react';
import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { Icon, SegmentAsync } from '@grafana/ui';
interface Props {
datasource: DataSourceRef;
filterKey: string | null;
onChange: (item: SelectableValue<string | null>) => void;
allFilters: AdHocVariableFilter[];
disabled?: boolean;
}
const MIN_WIDTH = 90;
export const AdHocFilterKey = ({ datasource, onChange, disabled, filterKey, allFilters }: Props) => {
const loadKeys = () => fetchFilterKeys(datasource, filterKey, allFilters);
const loadKeysWithRemove = () => fetchFilterKeysWithRemove(datasource, filterKey, allFilters);
if (filterKey === null) {
return (
<div className="gf-form" data-testid="AdHocFilterKey-add-key-wrapper">
<SegmentAsync
disabled={disabled}
className="query-segment-key"
Component={plusSegment}
value={filterKey}
onChange={onChange}
loadOptions={loadKeys}
inputMinWidth={MIN_WIDTH}
/>
</div>
);
}
return (
<div className="gf-form" data-testid="AdHocFilterKey-key-wrapper">
<SegmentAsync
disabled={disabled}
className="query-segment-key"
value={filterKey}
onChange={onChange}
loadOptions={loadKeysWithRemove}
inputMinWidth={MIN_WIDTH}
/>
</div>
);
};
export const REMOVE_FILTER_KEY = '-- remove filter --';
const REMOVE_VALUE = { label: REMOVE_FILTER_KEY, value: REMOVE_FILTER_KEY };
const plusSegment: ReactElement = (
<span className="gf-form-label query-part" aria-label="Add Filter">
<Icon name="plus" />
</span>
);
const fetchFilterKeys = async (
datasource: DataSourceRef,
currentKey: string | null,
allFilters: AdHocVariableFilter[]
): Promise<Array<SelectableValue<string>>> => {
const ds = await getDataSourceSrv().get(datasource);
if (!ds || !ds.getTagKeys) {
return [];
}
const otherFilters = allFilters.filter((f) => f.key !== currentKey);
const metrics = await ds.getTagKeys({ filters: otherFilters });
return metrics.map((m) => ({ label: m.text, value: m.text }));
};
const fetchFilterKeysWithRemove = async (
datasource: DataSourceRef,
currentKey: string | null,
allFilters: AdHocVariableFilter[]
): Promise<Array<SelectableValue<string>>> => {
const keys = await fetchFilterKeys(datasource, currentKey, allFilters);
return [REMOVE_VALUE, ...keys];
};

@ -0,0 +1,54 @@
import React from 'react';
import { AdHocVariableFilter, DataSourceRef, SelectableValue } from '@grafana/data';
import { AdHocFilterKey } from './AdHocFilterKey';
import { AdHocFilterValue } from './AdHocFilterValue';
import { OperatorSegment } from './OperatorSegment';
interface Props {
datasource: DataSourceRef;
filter: AdHocVariableFilter;
allFilters: AdHocVariableFilter[];
onKeyChange: (item: SelectableValue<string | null>) => void;
onOperatorChange: (item: SelectableValue<string>) => void;
onValueChange: (item: SelectableValue<string>) => void;
placeHolder?: string;
getTagKeysOptions?: any;
disabled?: boolean;
}
export const AdHocFilterRenderer = ({
datasource,
filter: { key, operator, value },
onKeyChange,
onOperatorChange,
onValueChange,
placeHolder,
allFilters,
disabled,
}: Props) => {
return (
<>
<AdHocFilterKey
disabled={disabled}
datasource={datasource}
filterKey={key}
onChange={onKeyChange}
allFilters={allFilters}
/>
<div className="gf-form">
<OperatorSegment disabled={disabled} value={operator} onChange={onOperatorChange} />
</div>
<AdHocFilterValue
disabled={disabled}
datasource={datasource}
filterKey={key}
filterValue={value}
allFilters={allFilters}
onChange={onValueChange}
placeHolder={placeHolder}
/>
</>
);
};

@ -0,0 +1,68 @@
import React from 'react';
import {
AdHocVariableFilter,
DataSourceRef,
MetricFindValue,
SelectableValue,
getDefaultTimeRange,
} from '@grafana/data';
// import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { getDataSourceSrv } from '@grafana/runtime';
import { SegmentAsync } from '@grafana/ui';
interface Props {
datasource: DataSourceRef;
filterKey: string;
filterValue?: string;
onChange: (item: SelectableValue<string>) => void;
placeHolder?: string;
disabled?: boolean;
allFilters: AdHocVariableFilter[];
}
export const AdHocFilterValue = ({
datasource,
disabled,
onChange,
filterKey,
filterValue,
placeHolder,
allFilters,
}: Props) => {
const loadValues = () => fetchFilterValues(datasource, filterKey, allFilters);
return (
<div className="gf-form" data-testid="AdHocFilterValue-value-wrapper">
<SegmentAsync
className="query-segment-value"
disabled={disabled}
placeholder={placeHolder}
value={filterValue}
onChange={onChange}
loadOptions={loadValues}
/>
</div>
);
};
const fetchFilterValues = async (
datasource: DataSourceRef,
key: string,
allFilters: AdHocVariableFilter[]
): Promise<Array<SelectableValue<string>>> => {
const ds = await getDataSourceSrv().get(datasource);
if (!ds || !ds.getTagValues) {
return [];
}
// const timeRange = getTimeSrv().timeRange();
// As https://github.com/grafana/grafana/pull/76118/files#diff-260d46415915a2e3e7d294e313bd128666e9f0868aa94d8aee4d4a24a060b542L27-R26
const timeRange = getDefaultTimeRange();
// Filter out the current filter key from the list of all filters
const otherFilters = allFilters.filter((f) => f.key !== key);
const metrics = await ds.getTagValues({ key, filters: otherFilters, timeRange });
return metrics.map((m: MetricFindValue) => ({ label: m.text, value: m.text }));
};

@ -0,0 +1,13 @@
import React from 'react';
interface Props {
label: string;
}
export const ConditionSegment = ({ label }: Props) => {
return (
<div className="gf-form">
<span className="gf-form-label query-keyword">{label}</span>
</div>
);
};

@ -0,0 +1,27 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { Segment } from '@grafana/ui';
interface Props {
value: string;
onChange: (item: SelectableValue<string>) => void;
disabled?: boolean;
}
const options = ['=', '!=', '<', '>', '=~', '!~'].map<SelectableValue<string>>((value) => ({
label: value,
value,
}));
export const OperatorSegment = ({ value, disabled, onChange }: Props) => {
return (
<Segment
className="query-segment-operator"
value={value}
disabled={disabled}
options={options}
onChange={onChange}
/>
);
};

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
type Props = {
description: string;
suffix: string;
feature: string;
};
export function ConfigDescriptionLink(props: Props) {
const { description, suffix, feature } = props;
const text = `Learn more about ${feature}`;
const styles = useStyles2(getStyles);
return (
<span className={styles.container}>
{description}
<a
aria-label={text}
href={`https://grafana.com/docs/grafana/next/datasources/${suffix}`}
rel="noreferrer"
target="_blank"
>
{text}
</a>
</span>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
color: theme.colors.text.secondary,
a: css({
color: theme.colors.text.link,
textDecoration: 'underline',
marginLeft: '5px',
'&:hover': {
textDecoration: 'none',
},
}),
}),
};
};

@ -0,0 +1,25 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export const Divider = ({ hideLine = false }) => {
const styles = useStyles2(getStyles);
if (hideLine) {
return <hr className={styles.dividerHideLine} />;
}
return <hr className={styles.divider} />;
};
const getStyles = (theme: GrafanaTheme2) => ({
divider: css`
margin: ${theme.spacing(4, 0)};
`,
dividerHideLine: css`
border: none;
margin: ${theme.spacing(3, 0)};
`,
});

@ -0,0 +1,86 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataSourceJsonData,
DataSourcePluginOptionsEditorProps,
GrafanaTheme2,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { ConfigSubSection } from '@grafana/experimental';
import { InlineField, InlineFieldRow, InlineSwitch, useStyles2 } from '@grafana/ui';
import { ConfigDescriptionLink } from './ConfigDescriptionLink';
export interface NodeGraphOptions {
enabled?: boolean;
}
export interface NodeGraphData extends DataSourceJsonData {
nodeGraph?: NodeGraphOptions;
}
interface Props extends DataSourcePluginOptionsEditorProps<NodeGraphData> {}
export function NodeGraphSettings({ options, onOptionsChange }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<InlineFieldRow className={styles.row}>
<InlineField
tooltip="Displays the node graph above the trace view. Default: disabled"
label="Enable node graph"
labelWidth={26}
>
<InlineSwitch
id="enableNodeGraph"
value={options.jsonData.nodeGraph?.enabled}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'nodeGraph', {
...options.jsonData.nodeGraph,
enabled: event.currentTarget.checked,
})
}
/>
</InlineField>
</InlineFieldRow>
</div>
);
}
export const NodeGraphSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => {
let suffix = options.type;
suffix += options.type === 'tempo' ? '/configure-tempo-data-source/#node-graph' : '/#node-graph';
return (
<ConfigSubSection
title="Node graph"
description={
<ConfigDescriptionLink
description="Show or hide the node graph visualization."
suffix={suffix}
feature="the node graph"
/>
}
>
<NodeGraphSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSubSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
infoText: css`
label: infoText;
padding-bottom: ${theme.spacing(2)};
color: ${theme.colors.text.secondary};
`,
container: css`
label: container;
width: 100%;
`,
row: css`
label: row;
align-items: baseline;
`,
});

@ -0,0 +1,112 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataSourceJsonData,
DataSourcePluginOptionsEditorProps,
GrafanaTheme2,
toOption,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { ConfigSubSection } from '@grafana/experimental';
import { InlineField, InlineFieldRow, Input, Select, useStyles2 } from '@grafana/ui';
import { ConfigDescriptionLink } from '../ConfigDescriptionLink';
export interface SpanBarOptions {
type?: string;
tag?: string;
}
export interface SpanBarOptionsData extends DataSourceJsonData {
spanBar?: SpanBarOptions;
}
export const NONE = 'None';
export const DURATION = 'Duration';
export const TAG = 'Tag';
interface Props extends DataSourcePluginOptionsEditorProps<SpanBarOptionsData> {}
export default function SpanBarSettings({ options, onOptionsChange }: Props) {
const styles = useStyles2(getStyles);
const selectOptions = [NONE, DURATION, TAG].map(toOption);
return (
<div className={css({ width: '100%' })}>
<InlineFieldRow className={styles.row}>
<InlineField label="Label" labelWidth={26} tooltip="Default: duration" grow>
<Select
inputId="label"
options={selectOptions}
value={options.jsonData.spanBar?.type || ''}
onChange={(v) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', {
...options.jsonData.spanBar,
type: v?.value ?? '',
});
}}
placeholder="Duration"
isClearable
aria-label={'select-label-name'}
width={40}
/>
</InlineField>
</InlineFieldRow>
{options.jsonData.spanBar?.type === TAG && (
<InlineFieldRow className={styles.row}>
<InlineField
label="Tag key"
labelWidth={26}
tooltip="Tag key which will be used to get the tag value. A span's attributes and resources will be searched for the tag key"
>
<Input
type="text"
placeholder="Enter tag key"
onChange={(v) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', {
...options.jsonData.spanBar,
tag: v.currentTarget.value,
})
}
value={options.jsonData.spanBar?.tag || ''}
width={40}
/>
</InlineField>
</InlineFieldRow>
)}
</div>
);
}
export const SpanBarSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => {
let suffix = options.type;
suffix += options.type === 'tempo' ? '/configure-tempo-data-source/#span-bar' : '/#span-bar';
return (
<ConfigSubSection
title="Span bar"
description={
<ConfigDescriptionLink
description="Add additional info next to the service and operation on a span bar row in the trace view."
suffix={suffix}
feature="the span bar"
/>
}
>
<SpanBarSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSubSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
infoText: css`
label: infoText;
padding-bottom: ${theme.spacing(2)};
color: ${theme.colors.text.secondary};
`,
row: css`
label: row;
align-items: baseline;
`,
});

@ -0,0 +1,46 @@
import { v4 as uuidv4 } from 'uuid';
import { AppNotification, AppNotificationSeverity } from '../actions/types';
import { getMessageFromError } from './errors';
const defaultSuccessNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Success,
icon: 'check',
};
const defaultErrorNotification = {
title: '',
text: '',
severity: AppNotificationSeverity.Error,
icon: 'exclamation-triangle',
};
export const createSuccessNotification = (title: string, text = '', traceId?: string): AppNotification => ({
...defaultSuccessNotification,
title,
text,
id: uuidv4(),
timestamp: Date.now(),
showing: true,
});
export const createErrorNotification = (
title: string,
text: string | Error = '',
traceId?: string,
component?: React.ReactElement
): AppNotification => {
return {
...defaultErrorNotification,
text: getMessageFromError(text),
title,
id: uuidv4(),
traceId,
component,
timestamp: Date.now(),
showing: true,
};
};

@ -0,0 +1,21 @@
import { isFetchError } from '@grafana/runtime';
export function getMessageFromError(err: unknown): string {
if (typeof err === 'string') {
return err;
}
if (err) {
if (err instanceof Error) {
return err.message;
} else if (isFetchError(err)) {
if (err.data && err.data.message) {
return err.data.message;
} else if (err.statusText) {
return err.statusText;
}
}
}
return JSON.stringify(err);
}

@ -0,0 +1,265 @@
import { LRUCache } from 'lru-cache';
import Prism from 'prismjs';
import { LanguageProvider, AbstractQuery, KeyValue } from '@grafana/data';
import { extractLabelMatchers, processLabels, toPromLikeExpr } from '../prometheus/language_utils';
import {
extractLabelKeysFromDataFrame,
extractLogParserFromDataFrame,
extractUnwrapLabelKeysFromDataFrame,
} from './responseUtils';
import syntax from './syntax';
import { ParserAndLabelKeysResult, LokiDatasource, LokiQuery, LokiQueryType } from './types';
const DEFAULT_MAX_LINES_SAMPLE = 10;
const NS_IN_MS = 1000000;
export default class LokiLanguageProvider extends LanguageProvider {
labelKeys: string[];
started = false;
datasource: LokiDatasource;
/**
* Cache for labels of series. This is bit simplistic in the sense that it just counts responses each as a 1 and does
* not account for different size of a response. If that is needed a `length` function can be added in the options.
* 10 as a max size is totally arbitrary right now.
*/
private seriesCache = new LRUCache<string, Record<string, string[]>>({ max: 10 });
private labelsCache = new LRUCache<string, string[]>({ max: 10 });
constructor(datasource: LokiDatasource, initialValues?: any) {
super();
this.datasource = datasource;
this.labelKeys = [];
Object.assign(this, initialValues);
}
request = async (url: string, params?: any) => {
try {
return await this.datasource.metadataRequest(url, params);
} catch (error) {
console.error(error);
}
return undefined;
};
/**
* Initialize the language provider by fetching set of labels.
*/
start = () => {
if (!this.startTask) {
this.startTask = this.fetchLabels().then(() => {
this.started = true;
return [];
});
}
return this.startTask;
};
/**
* Returns the label keys that have been fetched.
* If labels have not been fetched yet, it will return an empty array.
* For updated labels (which should not happen often), use fetchLabels.
* It is quite complicated to know when to use fetchLabels and when to use getLabelKeys.
* We should consider simplifying this and use caching in the same way as with seriesCache and labelsCache
* and just always use fetchLabels.
* Caching should be thought out properly, so we are not fetching this often, as labelKeys should not be changing often.
*
* @returns {string[]} An array of label keys or an empty array if labels have not been fetched.
*/
getLabelKeys(): string[] {
return this.labelKeys;
}
importFromAbstractQuery(labelBasedQuery: AbstractQuery): LokiQuery {
return {
refId: labelBasedQuery.refId,
expr: toPromLikeExpr(labelBasedQuery),
// queryType: LokiQueryType.Range,
queryType: LokiQueryType.Range,
};
}
exportToAbstractQuery(query: LokiQuery): AbstractQuery {
const lokiQuery = query.expr;
if (!lokiQuery || lokiQuery.length === 0) {
return { refId: query.refId, labelMatchers: [] };
}
const tokens = Prism.tokenize(lokiQuery, syntax);
return {
refId: query.refId,
labelMatchers: extractLabelMatchers(tokens),
};
}
/**
* Fetch all label keys
* This asynchronous function returns all available label keys from the data source.
* It returns a promise that resolves to an array of strings containing the label keys.
*
* @returns A promise containing an array of label keys.
* @throws An error if the fetch operation fails.
*/
async fetchLabels(): Promise<string[]> {
const url = 'labels';
const timeRange = this.datasource.getTimeRangeParams();
const res = await this.request(url, timeRange);
if (Array.isArray(res)) {
const labels = res
.slice()
.sort()
.filter((label) => label !== '__name__');
this.labelKeys = labels;
return this.labelKeys;
}
return [];
}
/**
* Fetch series labels for a selector
*
* This method fetches labels for a given stream selector, such as `{job="grafana"}`.
* It returns a promise that resolves to a record mapping label names to their corresponding values.
*
* @param streamSelector - The stream selector for which you want to retrieve labels.
* @returns A promise containing a record of label names and their values.
* @throws An error if the fetch operation fails.
*/
fetchSeriesLabels = async (streamSelector: string): Promise<Record<string, string[]>> => {
const interpolatedMatch = this.datasource.interpolateString(streamSelector);
const url = 'series';
const { start, end } = this.datasource.getTimeRangeParams();
const cacheKey = this.generateCacheKey(url, start, end, interpolatedMatch);
let value = this.seriesCache.get(cacheKey);
if (!value) {
const params = { 'match[]': interpolatedMatch, start, end };
const data = await this.request(url, params);
const { values } = processLabels(data);
value = values;
this.seriesCache.set(cacheKey, value);
}
return value;
};
/**
* Fetch series for a selector. Use this for raw results. Use fetchSeriesLabels() to get labels.
* @param match
*/
fetchSeries = async (match: string): Promise<Array<Record<string, string>>> => {
const url = 'series';
const { start, end } = this.datasource.getTimeRangeParams();
const params = { 'match[]': match, start, end };
return await this.request(url, params);
};
// Cache key is a bit different here. We round up to a minute the intervals.
// The rounding may seem strange but makes relative intervals like now-1h less prone to need separate request every
// millisecond while still actually getting all the keys for the correct interval. This still can create problems
// when user does not the newest values for a minute if already cached.
private generateCacheKey(url: string, start: number, end: number, param: string): string {
return [url, this.roundTime(start), this.roundTime(end), param].join();
}
// Round nanoseconds epoch to nearest 5 minute interval
private roundTime(nanoseconds: number): number {
return nanoseconds ? Math.floor(nanoseconds / NS_IN_MS / 1000 / 60 / 5) : 0;
}
/**
* Fetch label values
*
* This asynchronous function fetches values associated with a specified label name.
* It returns a promise that resolves to an array of strings containing the label values.
*
* @param labelName - The name of the label for which you want to retrieve values.
* @param options - (Optional) An object containing additional options - currently only stream selector.
* @param options.streamSelector - (Optional) The stream selector to filter label values. If not provided, all label values are fetched.
* @returns A promise containing an array of label values.
* @throws An error if the fetch operation fails.
*/
async fetchLabelValues(labelName: string, options?: { streamSelector?: string }): Promise<string[]> {
const label = encodeURIComponent(this.datasource.interpolateString(labelName));
const streamParam = options?.streamSelector
? encodeURIComponent(this.datasource.interpolateString(options.streamSelector))
: undefined;
const url = `label/${label}/values`;
const rangeParams = this.datasource.getTimeRangeParams();
const { start, end } = rangeParams;
const params: KeyValue<string | number> = { start, end };
let paramCacheKey = label;
if (streamParam) {
params.query = streamParam;
paramCacheKey += streamParam;
}
const cacheKey = this.generateCacheKey(url, start, end, paramCacheKey);
let labelValues = this.labelsCache.get(cacheKey);
if (!labelValues) {
// Clear value when requesting new one. Empty object being truthy also makes sure we don't request twice.
this.labelsCache.set(cacheKey, []);
const res = await this.request(url, params);
if (Array.isArray(res)) {
labelValues = res.slice().sort();
this.labelsCache.set(cacheKey, labelValues);
}
}
return labelValues ?? [];
}
/**
* Get parser and label keys for a selector
*
* This asynchronous function is used to fetch parsers and label keys for a selected log stream based on sampled lines.
* It returns a promise that resolves to an object with the following properties:
*
* - `extractedLabelKeys`: An array of available label keys associated with the log stream.
* - `hasJSON`: A boolean indicating whether JSON parsing is available for the stream.
* - `hasLogfmt`: A boolean indicating whether Logfmt parsing is available for the stream.
* - `hasPack`: A boolean indicating whether Pack parsing is available for the stream.
* - `unwrapLabelKeys`: An array of label keys that can be used for unwrapping log data.
*
* @param streamSelector - The selector for the log stream you want to analyze.
* @param {Object} [options] - Optional parameters.
* @param {number} [options.maxLines] - The number of log lines requested when determining parsers and label keys.
* Smaller maxLines is recommended for improved query performance. The default count is 10.
* @returns A promise containing an object with parser and label key information.
* @throws An error if the fetch operation fails.
*/
async getParserAndLabelKeys(
streamSelector: string,
options?: { maxLines?: number }
): Promise<ParserAndLabelKeysResult> {
const series = await this.datasource.getDataSamples({
expr: streamSelector,
refId: 'data-samples',
maxLines: options?.maxLines || DEFAULT_MAX_LINES_SAMPLE,
});
if (!series.length) {
return { extractedLabelKeys: [], unwrapLabelKeys: [], hasJSON: false, hasLogfmt: false, hasPack: false };
}
const { hasLogfmt, hasJSON, hasPack } = extractLogParserFromDataFrame(series[0]);
return {
extractedLabelKeys: extractLabelKeysFromDataFrame(series[0]),
unwrapLabelKeys: extractUnwrapLabelKeysFromDataFrame(series[0]),
hasJSON,
hasPack,
hasLogfmt,
};
}
}

@ -0,0 +1,90 @@
import React, { ReactNode } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { shouldRefreshLabels } from './languageUtils';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
import { LokiQuery, LokiOptions, LokiDatasource } from './types';
export interface LokiQueryFieldProps extends QueryEditorProps<LokiDatasource, LokiQuery, LokiOptions> {
ExtraFieldElement?: ReactNode;
placeholder?: string;
'data-testid'?: string;
}
interface LokiQueryFieldState {
labelsLoaded: boolean;
}
export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
_isMounted = false;
constructor(props: LokiQueryFieldProps) {
super(props);
this.state = { labelsLoaded: false };
}
async componentDidMount() {
this._isMounted = true;
await this.props.datasource.languageProvider.start();
if (this._isMounted) {
this.setState({ labelsLoaded: true });
}
}
componentWillUnmount() {
this._isMounted = false;
}
componentDidUpdate(prevProps: LokiQueryFieldProps) {
const {
range,
datasource: { languageProvider },
} = this.props;
const refreshLabels = shouldRefreshLabels(range, prevProps.range);
// We want to refresh labels when range changes (we round up intervals to a minute)
if (refreshLabels) {
languageProvider.fetchLabels();
}
}
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { query, onChange, onRunQuery } = this.props;
if (onChange) {
const nextQuery = { ...query, expr: value };
onChange(nextQuery);
if (override && onRunQuery) {
onRunQuery();
}
}
};
render() {
const { ExtraFieldElement, query, datasource, history, onRunQuery } = this.props;
const placeholder = this.props.placeholder ?? 'Enter a Loki query (run with Shift+Enter)';
return (
<>
<div
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={this.props['data-testid']}
>
<div className="gf-form--grow flex-shrink-1 min-width-15">
<MonacoQueryFieldWrapper
datasource={datasource}
history={history ?? []}
onChange={this.onChangeQuery}
onRunQuery={onRunQuery}
initialValue={query.expr ?? ''}
placeholder={placeholder}
/>
</div>
</div>
{ExtraFieldElement}
</>
);
}
}

@ -0,0 +1,65 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTSTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export enum QueryEditorMode {
Builder = 'builder',
Code = 'code',
}
export enum LokiQueryType {
Instant = 'instant',
Range = 'range',
Stream = 'stream',
}
export enum SupportingQueryType {
DataSample = 'dataSample',
LogsSample = 'logsSample',
LogsVolume = 'logsVolume',
}
export enum LokiQueryDirection {
Backward = 'backward',
Forward = 'forward',
}
export interface Loki extends common.DataQuery {
editorMode?: QueryEditorMode;
/**
* The LogQL query.
*/
expr: string;
/**
* @deprecated, now use queryType.
*/
instant?: boolean;
/**
* Used to override the name of the series.
*/
legendFormat?: string;
/**
* Used to limit the number of log rows returned.
*/
maxLines?: number;
/**
* @deprecated, now use queryType.
*/
range?: boolean;
/**
* @deprecated, now use step.
*/
resolution?: number;
/**
* Used to set step value for range queries.
*/
step?: string;
}

@ -0,0 +1,90 @@
import { TimeRange } from '@grafana/data';
function roundMsToMin(milliseconds: number): number {
return roundSecToMin(milliseconds / 1000);
}
function roundSecToMin(seconds: number): number {
return Math.floor(seconds / 60);
}
export function shouldRefreshLabels(range?: TimeRange, prevRange?: TimeRange): boolean {
if (range && prevRange) {
const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf());
const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf());
// If both are same, don't need to refresh
return !(sameMinuteFrom && sameMinuteTo);
}
return false;
}
// Loki regular-expressions use the RE2 syntax (https://github.com/google/re2/wiki/Syntax),
// so every character that matches something in that list has to be escaped.
// the list of meta characters is: *+?()|\.[]{}^$
// we make a javascript regular expression that matches those characters:
const RE2_METACHARACTERS = /[*+?()|\\.\[\]{}^$]/g;
function escapeLokiRegexp(value: string): string {
return value.replace(RE2_METACHARACTERS, '\\$&');
}
// based on the openmetrics-documentation, the 3 symbols we have to handle are:
// - \n ... the newline character
// - \ ... the backslash character
// - " ... the double-quote character
export function escapeLabelValueInExactSelector(labelValue: string): string {
return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
export function unescapeLabelValue(labelValue: string): string {
return labelValue.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
export function escapeLabelValueInRegexSelector(labelValue: string): string {
return escapeLabelValueInExactSelector(escapeLokiRegexp(labelValue));
}
export function escapeLabelValueInSelector(labelValue: string, selector?: string): string {
return isRegexSelector(selector)
? escapeLabelValueInRegexSelector(labelValue)
: escapeLabelValueInExactSelector(labelValue);
}
export function isRegexSelector(selector?: string) {
if (selector && (selector.includes('=~') || selector.includes('!~'))) {
return true;
}
return false;
}
export function isBytesString(string: string) {
const BYTES_KEYWORDS = [
'b',
'kib',
'Kib',
'kb',
'KB',
'mib',
'Mib',
'mb',
'MB',
'gib',
'Gib',
'gb',
'GB',
'tib',
'Tib',
'tb',
'TB',
'pib',
'Pib',
'pb',
'PB',
'eib',
'Eib',
'eb',
'EB',
];
const regex = new RegExp(`^(?:-?\\d+(?:\\.\\d+)?)(?:${BYTES_KEYWORDS.join('|')})$`);
const match = string.match(regex);
return !!match;
}

@ -0,0 +1,28 @@
export function isLogLineJSON(line: string): boolean {
let parsed;
try {
parsed = JSON.parse(line);
} catch (error) {}
// The JSON parser should only be used for log lines that are valid serialized JSON objects.
return typeof parsed === 'object';
}
// This matches:
// first a label from start of the string or first white space, then any word chars until "="
// second either an empty quotes, or anything that starts with quote and ends with unescaped quote,
// or any non whitespace chars that do not start with quote
const LOGFMT_REGEXP = /(?:^|\s)([\w\(\)\[\]\{\}]+)=(""|(?:".*?[^\\]"|[^"\s]\S*))/;
export function isLogLineLogfmt(line: string): boolean {
return LOGFMT_REGEXP.test(line);
}
export function isLogLinePacked(line: string): boolean {
let parsed;
try {
parsed = JSON.parse(line);
return parsed.hasOwnProperty('_entry');
} catch (error) {
return false;
}
}

@ -0,0 +1,273 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { useRef, useEffect } from 'react';
import { useLatest } from 'react-use';
import { v4 as uuidv4 } from 'uuid';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { parser } from '@grafana/lezer-logql';
import { languageConfiguration, monarchlanguage } from '@grafana/monaco-logql';
import { useTheme2, ReactMonacoEditor, Monaco, monacoTypes, MonacoEditor } from '@grafana/ui';
import { Props } from './MonacoQueryFieldProps';
import { getOverrideServices } from './getOverrideServices';
import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
import { CompletionDataProvider } from './monaco-completion-provider/CompletionDataProvider';
import { placeHolderScopedVars, validateQuery } from './monaco-completion-provider/validation';
const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
codeLens: false,
contextmenu: false,
// we need `fixedOverflowWidgets` because otherwise in grafana-dashboards
// the popup is clipped by the panel-visualizations.
fixedOverflowWidgets: true,
folding: false,
fontSize: 14,
lineDecorationsWidth: 8, // used as "padding-left"
lineNumbers: 'off',
minimap: { enabled: false },
overviewRulerBorder: false,
overviewRulerLanes: 0,
padding: {
// these numbers were picked so that visually this matches the previous version
// of the query-editor the best
top: 4,
bottom: 5,
},
renderLineHighlight: 'none',
scrollbar: {
vertical: 'hidden',
verticalScrollbarSize: 8, // used as "padding-right"
horizontal: 'hidden',
horizontalScrollbarSize: 0,
alwaysConsumeMouseWheel: false,
},
scrollBeyondLastLine: false,
suggest: getSuggestOptions(),
suggestFontSize: 12,
wordWrap: 'on',
};
// this number was chosen by testing various values. it might be necessary
// because of the width of the border, not sure.
//it needs to do 2 things:
// 1. when the editor is single-line, it should make the editor height be visually correct
// 2. when the editor is multi-line, the editor should not be "scrollable" (meaning,
// you do a scroll-movement in the editor, and it will scroll the content by a couple pixels
// up & down. this we want to avoid)
const EDITOR_HEIGHT_OFFSET = 2;
const LANG_ID = 'logql';
// we must only run the lang-setup code once
let LANGUAGE_SETUP_STARTED = false;
export const defaultWordPattern = /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g;
function ensureLogQL(monaco: Monaco) {
if (LANGUAGE_SETUP_STARTED === false) {
LANGUAGE_SETUP_STARTED = true;
monaco.languages.register({ id: LANG_ID });
monaco.languages.setMonarchTokensProvider(LANG_ID, monarchlanguage);
monaco.languages.setLanguageConfiguration(LANG_ID, {
...languageConfiguration,
wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()+\[{\]}\\|;:',.<>\/?\s]+)/g,
// Default: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g
// Removed `"`, `=`, and `-`, from the exclusion list, so now the completion provider can decide to overwrite any matching words, or just insert text at the cursor
});
}
}
const getStyles = (theme: GrafanaTheme2, placeholder: string) => {
return {
container: css`
border-radius: ${theme.shape.radius.default};
border: 1px solid ${theme.components.input.borderColor};
width: 100%;
.monaco-editor .suggest-widget {
min-width: 50%;
}
`,
placeholder: css`
::after {
content: '${placeholder}';
font-family: ${theme.typography.fontFamilyMonospace};
opacity: 0.3;
}
`,
};
};
const MonacoQueryField = ({ history, onBlur, onRunQuery, initialValue, datasource, placeholder, onChange }: Props) => {
const id = uuidv4();
// we need only one instance of `overrideServices` during the lifetime of the react component
const overrideServicesRef = useRef(getOverrideServices());
const containerRef = useRef<HTMLDivElement>(null);
const langProviderRef = useLatest(datasource.languageProvider);
const historyRef = useLatest(history);
const onRunQueryRef = useLatest(onRunQuery);
const onBlurRef = useLatest(onBlur);
const autocompleteCleanupCallback = useRef<(() => void) | null>(null);
const theme = useTheme2();
const styles = getStyles(theme, placeholder);
useEffect(() => {
// when we unmount, we unregister the autocomplete-function, if it was registered
return () => {
autocompleteCleanupCallback.current?.();
};
}, []);
const setPlaceholder = (monaco: Monaco, editor: MonacoEditor) => {
const placeholderDecorators = [
{
range: new monaco.Range(1, 1, 1, 1),
options: {
className: styles.placeholder,
isWholeLine: true,
},
},
];
let decorators: string[] = [];
const checkDecorators: () => void = () => {
const model = editor.getModel();
if (!model) {
return;
}
const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : [];
decorators = model.deltaDecorations(decorators, newDecorators);
};
checkDecorators();
editor.onDidChangeModelContent(checkDecorators);
};
const onTypeDebounced = debounce(async (query: string) => {
onChange(query);
}, 1000);
return (
<div
aria-label={selectors.components.QueryField.container}
className={styles.container}
// NOTE: we will be setting inline-style-width/height on this element
ref={containerRef}
>
<ReactMonacoEditor
overrideServices={overrideServicesRef.current}
options={options}
language={LANG_ID}
value={initialValue}
beforeMount={(monaco) => {
ensureLogQL(monaco);
}}
onMount={(editor, monaco) => {
// Monaco has a bug where it runs actions on all instances (https://github.com/microsoft/monaco-editor/issues/2947), so we ensure actions are executed on instance-level with this ContextKey.
const isEditorFocused = editor.createContextKey<boolean>('isEditorFocused' + id, false);
// we setup on-blur
editor.onDidBlurEditorWidget(() => {
isEditorFocused.set(false);
onBlurRef.current(editor.getValue());
});
editor.onDidChangeModelContent((e) => {
const model = editor.getModel();
if (!model) {
return;
}
const query = model.getValue();
const errors =
validateQuery(
query,
datasource.interpolateString(query, placeHolderScopedVars),
model.getLinesContent(),
parser
) || [];
const markers = errors.map(({ error, ...boundary }: any) => ({
message: `${
error ? `Error parsing "${error}"` : 'Parse error'
}. The query appears to be incorrect and could fail to be executed.`,
severity: monaco.MarkerSeverity.Error,
...boundary,
}));
onTypeDebounced(query);
monaco.editor.setModelMarkers(model, 'owner', markers);
});
const dataProvider = new CompletionDataProvider(langProviderRef.current, historyRef);
const completionProvider = getCompletionProvider(monaco, dataProvider);
// completion-providers in monaco are not registered directly to editor-instances,
// they are registered to languages. this makes it hard for us to have
// separate completion-providers for every query-field-instance
// (but we need that, because they might connect to different datasources).
// the trick we do is, we wrap the callback in a "proxy",
// and in the proxy, the first thing is, we check if we are called from
// "our editor instance", and if not, we just return nothing. if yes,
// we call the completion-provider.
const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = {
...completionProvider,
provideCompletionItems: (model, position, context, token) => {
// if the model-id does not match, then this call is from a different editor-instance,
// not "our instance", so return nothing
if (editor.getModel()?.id !== model.id) {
return { suggestions: [] };
}
return completionProvider.provideCompletionItems(model, position, context, token);
},
};
const { dispose } = monaco.languages.registerCompletionItemProvider(LANG_ID, filteringCompletionProvider);
autocompleteCleanupCallback.current = dispose;
// this code makes the editor resize itself so that the content fits
// (it will grow taller when necessary)
// FIXME: maybe move this functionality into CodeEditor, like:
// <CodeEditor resizingMode="single-line"/>
const handleResize = () => {
const containerDiv = containerRef.current;
if (containerDiv !== null) {
const pixelHeight = editor.getContentHeight();
containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
const pixelWidth = containerDiv.clientWidth;
editor.layout({ width: pixelWidth, height: pixelHeight });
}
};
editor.onDidContentSizeChange(handleResize);
handleResize();
// handle: shift + enter
// FIXME: maybe move this functionality into CodeEditor?
editor.addCommand(
monaco.KeyMod.Shift | monaco.KeyCode.Enter,
() => {
onRunQueryRef.current(editor.getValue());
},
'isEditorFocused' + id
);
editor.onDidFocusEditorText(() => {
isEditorFocused.set(true);
if (editor.getValue().trim() === '') {
editor.trigger('', 'editor.action.triggerSuggest', {});
}
});
setPlaceholder(monaco, editor);
}}
/>
</div>
);
};
// Default export for lazy load.
export default MonacoQueryField;

@ -0,0 +1,13 @@
import React, { Suspense } from 'react';
import { Props } from './MonacoQueryFieldProps';
const Field = React.lazy(() => import(/* webpackChunkName: "loki-query-field" */ './MonacoQueryField'));
export const MonacoQueryFieldLazy = (props: Props) => {
return (
<Suspense fallback={null}>
<Field {...props} />
</Suspense>
);
};

@ -0,0 +1,17 @@
import { HistoryItem } from '@grafana/data';
import { LokiDatasource, LokiQuery } from '../types';
// we need to store this in a separate file,
// because we have an async-wrapper around,
// the react-component, and it needs the same
// props as the sync-component.
export type Props = {
initialValue: string;
history: Array<HistoryItem<LokiQuery>>;
onRunQuery: (value: string) => void;
onBlur: (value: string) => void;
placeholder: string;
datasource: LokiDatasource;
onChange: (query: string) => void;
};

@ -0,0 +1,27 @@
import React, { useRef } from 'react';
import { MonacoQueryFieldLazy } from './MonacoQueryFieldLazy';
import { Props as MonacoProps } from './MonacoQueryFieldProps';
export type Props = Omit<MonacoProps, 'onRunQuery' | 'onBlur'> & {
onChange: (query: string) => void;
onRunQuery: () => void;
onQueryType?: (query: string) => void;
};
export const MonacoQueryFieldWrapper = (props: Props) => {
const lastRunValueRef = useRef<string | null>(null);
const { onRunQuery, onChange, ...rest } = props;
const handleRunQuery = (value: string) => {
lastRunValueRef.current = value;
onChange(value);
onRunQuery();
};
const handleBlur = (value: string) => {
onChange(value);
};
return <MonacoQueryFieldLazy onRunQuery={handleRunQuery} onBlur={handleBlur} onChange={onChange} {...rest} />;
};

@ -0,0 +1,112 @@
import { monacoTypes } from '@grafana/ui';
// this thing here is a workaround in a way.
// what we want to achieve, is that when the autocomplete-window
// opens, the "second, extra popup" with the extra help,
// also opens automatically.
// but there is no API to achieve it.
// the way to do it is to implement the `storageService`
// interface, and provide our custom implementation,
// which will default to `true` for the correct string-key.
// unfortunately, while the typescript-interface exists,
// it is not exported from monaco-editor,
// so we cannot rely on typescript to make sure
// we do it right. all we can do is to manually
// lookup the interface, and make sure we code our code right.
// our code is a "best effort" approach,
// i am not 100% how the `scope` and `target` things work,
// but so far it seems to work ok.
// i would use an another approach, if there was one available.
function makeStorageService() {
// we need to return an object that fulfills this interface:
// https://github.com/microsoft/vscode/blob/ff1e16eebb93af79fd6d7af1356c4003a120c563/src/vs/platform/storage/common/storage.ts#L37
// unfortunately it is not export from monaco-editor
const strings = new Map<string, string>();
// we want this to be true by default
strings.set('expandSuggestionDocs', true.toString());
return {
// we do not implement the on* handlers
onDidChangeValue: (data: unknown): void => undefined,
onDidChangeTarget: (data: unknown): void => undefined,
onWillSaveState: (data: unknown): void => undefined,
get: (key: string, scope: unknown, fallbackValue?: string): string | undefined => {
return strings.get(key) ?? fallbackValue;
},
getBoolean: (key: string, scope: unknown, fallbackValue?: boolean): boolean | undefined => {
const val = strings.get(key);
if (val !== undefined) {
// the interface docs say the value will be converted
// to a boolean but do not specify how, so we improvise
return val === 'true';
} else {
return fallbackValue;
}
},
getNumber: (key: string, scope: unknown, fallbackValue?: number): number | undefined => {
const val = strings.get(key);
if (val !== undefined) {
return parseInt(val, 10);
} else {
return fallbackValue;
}
},
store: (
key: string,
value: string | boolean | number | undefined | null,
scope: unknown,
target: unknown
): void => {
// the interface docs say if the value is nullish, it should act as delete
if (value === null || value === undefined) {
strings.delete(key);
} else {
strings.set(key, value.toString());
}
},
remove: (key: string, scope: unknown): void => {
strings.delete(key);
},
keys: (scope: unknown, target: unknown): string[] => {
return Array.from(strings.keys());
},
logStorage: (): void => {
console.log('logStorage: not implemented');
},
migrate: (): Promise<void> => {
// we do not implement this
return Promise.resolve(undefined);
},
isNew: (scope: unknown): boolean => {
// we create a new storage for every session, we do not persist it,
// so we return `true`.
return true;
},
flush: (reason?: unknown): Promise<void> => {
// we do not implement this
return Promise.resolve(undefined);
},
};
}
let overrideServices: monacoTypes.editor.IEditorOverrideServices = {
storageService: makeStorageService(),
};
export function getOverrideServices(): monacoTypes.editor.IEditorOverrideServices {
// One instance of this for every query editor
return overrideServices;
}

@ -0,0 +1,97 @@
import { chain } from 'lodash';
import { HistoryItem } from '@grafana/data';
import { LokiQuery, ParserAndLabelKeysResult, LanguageProvider } from '../../types';
export function escapeLabelValueInExactSelector(labelValue: string): string {
return labelValue.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"');
}
import { Label } from './situation';
interface HistoryRef {
current: Array<HistoryItem<LokiQuery>>;
}
export class CompletionDataProvider {
constructor(
private languageProvider: LanguageProvider,
private historyRef: HistoryRef = { current: [] }
) {
this.queryToLabelKeysCache = new Map();
}
private queryToLabelKeysCache: Map<string, ParserAndLabelKeysResult>;
private buildSelector(labels: Label[]): string {
const allLabelTexts = labels.map(
(label) => `${label.name}${label.op}"${escapeLabelValueInExactSelector(label.value)}"`
);
return `{${allLabelTexts.join(',')}}`;
}
getHistory() {
return chain(this.historyRef.current)
.map((history: HistoryItem<LokiQuery>) => history.query.expr)
.filter()
.uniq()
.value();
}
async getLabelNames(otherLabels: Label[] = []) {
if (otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return this.languageProvider.getLabelKeys();
}
const data = await this.getSeriesLabels(otherLabels);
const possibleLabelNames = Object.keys(data); // all names from datasource
const usedLabelNames = new Set(otherLabels.map((l) => l.name)); // names used in the query
return possibleLabelNames.filter((label) => !usedLabelNames.has(label));
}
async getLabelValues(labelName: string, otherLabels: Label[]) {
if (otherLabels.length === 0) {
// if there is no filtering, we have to use a special endpoint
return await this.languageProvider.fetchLabelValues(labelName);
}
const data = await this.getSeriesLabels(otherLabels);
return data[labelName] ?? [];
}
/**
* Runs a Loki query to extract label keys from the result.
* The result is cached for the query string.
*
* Since various "situations" in the monaco code editor trigger this function, it is prone to being called multiple times for the same query
* Here is a lightweight and simple cache to avoid calling the backend multiple times for the same query.
*
* @param logQuery
*/
async getParserAndLabelKeys(logQuery: string): Promise<ParserAndLabelKeysResult> {
const EXTRACTED_LABEL_KEYS_MAX_CACHE_SIZE = 2;
const cachedLabelKeys = this.queryToLabelKeysCache.has(logQuery) ? this.queryToLabelKeysCache.get(logQuery) : null;
if (cachedLabelKeys) {
// cache hit! Serve stale result from cache
return cachedLabelKeys;
} else {
// If cache is larger than max size, delete the first (oldest) index
if (this.queryToLabelKeysCache.size >= EXTRACTED_LABEL_KEYS_MAX_CACHE_SIZE) {
// Make room in the cache for the fresh result by deleting the "first" index
const keys = this.queryToLabelKeysCache.keys();
const firstKey = keys.next().value;
this.queryToLabelKeysCache.delete(firstKey);
}
// Fetch a fresh result from the backend
const labelKeys = await this.languageProvider.getParserAndLabelKeys(logQuery);
// Add the result to the cache
this.queryToLabelKeysCache.set(logQuery, labelKeys);
return labelKeys;
}
}
async getSeriesLabels(labels: Label[]) {
return await this.languageProvider.fetchSeriesLabels(this.buildSelector(labels)).then((data: any) => data ?? {});
}
}

@ -0,0 +1,22 @@
// This helper class is used to make typescript warn you when you miss a case-block in a switch statement.
// For example:
//
// const x:'A'|'B'|'C' = 'A';
//
// switch(x) {
// case 'A':
// // something
// case 'B':
// // something
// default:
// throw new NeverCaseError(x);
// }
//
//
// TypeScript detect the missing case and display an error.
export class NeverCaseError extends Error {
constructor(value: never) {
super(`Unexpected case in switch statement: ${JSON.stringify(value)}`);
}
}

@ -0,0 +1,201 @@
import type { Monaco, monacoTypes } from '@grafana/ui';
import { CompletionDataProvider } from './CompletionDataProvider';
import { NeverCaseError } from './NeverCaseError';
import { Situation, getSituation } from './situation';
type CompletionType =
| 'HISTORY'
| 'FUNCTION'
| 'DURATION'
| 'LABEL_NAME'
| 'LABEL_VALUE'
| 'PATTERN'
| 'PARSER'
| 'LINE_FILTER'
| 'PIPE_OPERATION';
type Completion = {
type: CompletionType;
label: string;
insertText: string;
detail?: string;
documentation?: string;
triggerOnInsert?: boolean;
isSnippet?: boolean;
};
const DURATION_COMPLETIONS: Completion[] = ['$__auto', '1m', '5m', '10m', '30m', '1h', '1d'].map((text) => ({
type: 'DURATION',
label: text,
insertText: text,
}));
const getCompletions = async (situation: Situation, dataProvider: CompletionDataProvider) => {
return DURATION_COMPLETIONS;
};
// from: monacoTypes.languages.CompletionItemInsertTextRule.InsertAsSnippet
const INSERT_AS_SNIPPET_ENUM_VALUE = 4;
export function getSuggestOptions(): monacoTypes.editor.ISuggestOptions {
return {
// monaco-editor sometimes provides suggestions automatically, i am not
// sure based on what, seems to be by analyzing the words already
// written.
// to try it out:
// - enter `go_goroutines{job~`
// - have the cursor at the end of the string
// - press ctrl-enter
// - you will get two suggestions
// those were not provided by grafana, they are offered automatically.
// i want to remove those. the only way i found is:
// - every suggestion-item has a `kind` attribute,
// that controls the icon to the left of the suggestion.
// - items auto-generated by monaco have `kind` set to `text`.
// - we make sure grafana-provided suggestions do not have `kind` set to `text`.
// - and then we tell monaco not to show suggestions of kind `text`
showWords: false,
};
}
function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind {
switch (type) {
case 'DURATION':
return monaco.languages.CompletionItemKind.Unit;
case 'FUNCTION':
return monaco.languages.CompletionItemKind.Variable;
case 'HISTORY':
return monaco.languages.CompletionItemKind.Snippet;
case 'LABEL_NAME':
return monaco.languages.CompletionItemKind.Enum;
case 'LABEL_VALUE':
return monaco.languages.CompletionItemKind.EnumMember;
case 'PATTERN':
return monaco.languages.CompletionItemKind.Constructor;
case 'PARSER':
return monaco.languages.CompletionItemKind.Class;
case 'LINE_FILTER':
return monaco.languages.CompletionItemKind.TypeParameter;
case 'PIPE_OPERATION':
return monaco.languages.CompletionItemKind.Interface;
default:
throw new NeverCaseError(type as never);
}
}
export function getCompletionProvider(
monaco: Monaco,
dataProvider: CompletionDataProvider
): monacoTypes.languages.CompletionItemProvider {
const provideCompletionItems = (
model: monacoTypes.editor.ITextModel,
position: monacoTypes.Position
): monacoTypes.languages.ProviderResult<monacoTypes.languages.CompletionList> => {
const word = model.getWordAtPosition(position);
const wordUntil = model.getWordUntilPosition(position);
// documentation says `position` will be "adjusted" in `getOffsetAt`
// i don't know what that means, to be sure i clone it
const positionClone = {
column: position.column,
lineNumber: position.lineNumber,
};
const offset = model.getOffsetAt(positionClone);
const situation = getSituation(model.getValue(), offset);
const range = calculateRange(situation, word, wordUntil, monaco, position);
const completionsPromise = situation != null ? getCompletions(situation, dataProvider) : Promise.resolve([]);
return completionsPromise.then((items) => {
// monaco by default alphabetically orders the items.
// to stop it, we use a number-as-string sortkey,
// so that monaco keeps the order we use
const maxIndexDigits = items.length.toString().length;
const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => ({
kind: getMonacoCompletionItemKind(item.type, monaco),
label: item.label,
insertText: item.insertText,
insertTextRules: item.isSnippet ? INSERT_AS_SNIPPET_ENUM_VALUE : undefined,
detail: item.detail,
documentation: item.documentation,
sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have
range: range,
command: item.triggerOnInsert
? {
id: 'editor.action.triggerSuggest',
title: '',
}
: undefined,
}));
return { suggestions };
});
};
return {
triggerCharacters: ['{', ',', '[', '(', '=', '~', ' ', '"', '|'],
provideCompletionItems,
};
}
export const calculateRange = (
situation: Situation | null,
word: monacoTypes.editor.IWordAtPosition | null,
wordUntil: monacoTypes.editor.IWordAtPosition,
monaco: Monaco,
position: monacoTypes.Position
): monacoTypes.Range => {
if (
situation &&
situation?.type === 'IN_LABEL_SELECTOR_WITH_LABEL_NAME' &&
'betweenQuotes' in situation &&
situation.betweenQuotes
) {
// Word until won't have second quote if they are between quotes
const indexOfFirstQuote = wordUntil?.word?.indexOf('"') ?? 0;
const indexOfLastQuote = word?.word?.lastIndexOf('"') ?? 0;
const indexOfEquals = word?.word.indexOf('=');
const indexOfLastEquals = word?.word.lastIndexOf('=');
// Just one equals "=" the cursor is somewhere within a label value
// e.g. value="labe^l-value" or value="^label-value" etc
// We want the word to include everything within the quotes, so the result from autocomplete overwrites the existing label value
if (
indexOfLastEquals === indexOfEquals &&
indexOfFirstQuote !== -1 &&
indexOfLastQuote !== -1 &&
indexOfLastEquals !== -1
) {
return word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: wordUntil.startColumn + indexOfFirstQuote + 1,
endColumn: wordUntil.startColumn + indexOfLastQuote,
})
: monaco.Range.fromPositions(position);
}
}
if (situation && situation.type === 'IN_LABEL_SELECTOR_WITH_LABEL_NAME') {
// Otherwise we want the range to be calculated as the cursor position, as we want to insert the autocomplete, instead of overwriting existing text
// The cursor position is the length of the wordUntil
return word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: wordUntil.endColumn,
endColumn: wordUntil.endColumn,
})
: monaco.Range.fromPositions(position);
}
// And for all other non-label cases, we want to use the word start and end column
return word != null
? monaco.Range.lift({
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
})
: monaco.Range.fromPositions(position);
};

@ -0,0 +1,67 @@
export type LabelOperator = '=' | '!=' | '=~' | '!~';
export type Label = {
name: string;
value: string;
op: LabelOperator;
};
export type Situation =
| {
type: 'EMPTY';
}
| {
type: 'AT_ROOT';
}
| {
type: 'IN_LOGFMT';
otherLabels: string[];
flags: boolean;
trailingSpace: boolean;
trailingComma: boolean;
logQuery: string;
}
| {
type: 'IN_RANGE';
}
| {
type: 'IN_AGGREGATION';
}
| {
type: 'IN_GROUPING';
logQuery: string;
}
| {
type: 'IN_LABEL_SELECTOR_NO_LABEL_NAME';
otherLabels: Label[];
}
| {
type: 'IN_LABEL_SELECTOR_WITH_LABEL_NAME';
labelName: string;
betweenQuotes: boolean;
otherLabels: Label[];
}
| {
type: 'AFTER_SELECTOR';
afterPipe: boolean;
hasSpace: boolean;
logQuery: string;
}
| {
type: 'AFTER_UNWRAP';
logQuery: string;
}
| {
type: 'AFTER_KEEP_AND_DROP';
logQuery: string;
};
/**
* THIS METHOD IS KNOWN TO BE INCOMPLETE due to the decoupling of the Tempo datasource from Grafana core:
* Incomplete support for LogQL autocomplete from 'public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/situation.ts';
*/
export const getSituation = (text: string, pos: number): Situation | null => {
return {
type: 'EMPTY',
};
};

@ -0,0 +1,126 @@
import { SyntaxNode } from '@lezer/common';
import { LRParser } from '@lezer/lr';
// import { ErrorId } from 'app/plugins/datasource/prometheus/querybuilder/shared/parsingUtils';
const ErrorId = 0;
interface ParserErrorBoundary {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
error: string;
}
interface ParseError {
text: string;
node: SyntaxNode;
}
/**
* Conceived to work in combination with the MonacoQueryField component.
* Given an original query, and it's interpolated version, it will return an array of ParserErrorBoundary
* objects containing nodes which are actual errors. The interpolated version (even with placeholder variables)
* is required because variables look like errors for Lezer.
* @internal
*/
export function validateQuery(
query: string,
interpolatedQuery: string,
queryLines: string[],
parser: LRParser
): ParserErrorBoundary[] | false {
if (!query) {
return false;
}
/**
* To provide support to variable interpolation in query validation, we run the parser in the interpolated
* query. If there are errors there, we trace them back to the original unparsed query, so we can more
* accurately highlight the error in the query, since it's likely that the variable name and variable value
* have different lengths. With this, we also exclude irrelevant parser errors that are produced by
* lezer not understanding $variables and $__variables, which usually generate 2 or 3 error SyntaxNode.
*/
const interpolatedErrors: ParseError[] = parseQuery(interpolatedQuery, parser);
if (!interpolatedErrors.length) {
return false;
}
let parseErrors: ParseError[] = interpolatedErrors;
if (query !== interpolatedQuery) {
const queryErrors: ParseError[] = parseQuery(query, parser);
parseErrors = interpolatedErrors.flatMap(
(interpolatedError) =>
queryErrors.filter((queryError) => interpolatedError.text === queryError.text) || interpolatedError
);
}
return parseErrors.map((parseError) => findErrorBoundary(query, queryLines, parseError)).filter(isErrorBoundary);
}
function parseQuery(query: string, parser: LRParser) {
const parseErrors: ParseError[] = [];
const tree = parser.parse(query);
tree.iterate({
enter: (nodeRef): false | void => {
if (nodeRef.type.id === ErrorId) {
const node = nodeRef.node;
parseErrors.push({
node: node,
text: query.substring(node.from, node.to),
});
}
},
});
return parseErrors;
}
function findErrorBoundary(query: string, queryLines: string[], parseError: ParseError): ParserErrorBoundary | null {
if (queryLines.length === 1) {
const isEmptyString = parseError.node.from === parseError.node.to;
const errorNode = isEmptyString && parseError.node.parent ? parseError.node.parent : parseError.node;
const error = isEmptyString ? query.substring(errorNode.from, errorNode.to) : parseError.text;
return {
startLineNumber: 1,
startColumn: errorNode.from + 1,
endLineNumber: 1,
endColumn: errorNode.to + 1,
error,
};
}
let startPos = 0,
endPos = 0;
for (let line = 0; line < queryLines.length; line++) {
endPos = startPos + queryLines[line].length;
if (parseError.node.from > endPos) {
startPos += queryLines[line].length + 1;
continue;
}
return {
startLineNumber: line + 1,
startColumn: parseError.node.from - startPos + 1,
endLineNumber: line + 1,
endColumn: parseError.node.to - startPos + 1,
error: parseError.text,
};
}
return null;
}
function isErrorBoundary(boundary: ParserErrorBoundary | null): boundary is ParserErrorBoundary {
return boundary !== null;
}
export const placeHolderScopedVars = {
__interval: { text: '1s', value: '1s' },
__rate_interval: { text: '1s', value: '1s' },
__auto: { text: '1s', value: '1s' },
__interval_ms: { text: '1000', value: 1000 },
__range_ms: { text: '1000', value: 1000 },
__range_s: { text: '1', value: 1 },
__range: { text: '1s', value: '1s' },
};

@ -0,0 +1,260 @@
import {
DataFrame,
DataFrameType,
DataQueryResponse,
DataQueryResponseData,
Field,
FieldType,
isValidGoDuration,
Labels,
QueryResultMetaStat,
shallowCompare,
} from '@grafana/data';
import { isBytesString } from './languageUtils';
import { isLogLineJSON, isLogLineLogfmt, isLogLinePacked } from './lineParser';
export function dataFrameHasLokiError(frame: DataFrame): boolean {
const labelSets: Labels[] = frame.fields.find((f) => f.name === 'labels')?.values ?? [];
return labelSets.some((labels) => labels.__error__ !== undefined);
}
export function dataFrameHasLevelLabel(frame: DataFrame): boolean {
const labelSets: Labels[] = frame.fields.find((f) => f.name === 'labels')?.values ?? [];
return labelSets.some((labels) => labels.level !== undefined);
}
export function extractLogParserFromDataFrame(frame: DataFrame): {
hasLogfmt: boolean;
hasJSON: boolean;
hasPack: boolean;
} {
const lineField = frame.fields.find((field) => field.type === FieldType.string);
if (lineField == null) {
return { hasJSON: false, hasLogfmt: false, hasPack: false };
}
const logLines: string[] = lineField.values;
let hasJSON = false;
let hasLogfmt = false;
let hasPack = false;
logLines.forEach((line) => {
if (isLogLineJSON(line)) {
hasJSON = true;
hasPack = isLogLinePacked(line);
}
if (isLogLineLogfmt(line)) {
hasLogfmt = true;
}
});
return { hasLogfmt, hasJSON, hasPack };
}
export function extractLabelKeysFromDataFrame(frame: DataFrame): string[] {
const labelsArray: Array<{ [key: string]: string }> | undefined =
frame?.fields?.find((field) => field.name === 'labels')?.values ?? [];
if (!labelsArray?.length) {
return [];
}
return Object.keys(labelsArray[0]);
}
export function extractUnwrapLabelKeysFromDataFrame(frame: DataFrame): string[] {
const labelsArray: Array<{ [key: string]: string }> | undefined =
frame?.fields?.find((field) => field.name === 'labels')?.values ?? [];
if (!labelsArray?.length) {
return [];
}
// We do this only for first label object, because we want to consider only labels that are present in all log lines
// possibleUnwrapLabels are labels with 1. number value OR 2. value that is valid go duration OR 3. bytes string value
const possibleUnwrapLabels = Object.keys(labelsArray[0]).filter((key) => {
const value = labelsArray[0][key];
if (!value) {
return false;
}
return !isNaN(Number(value)) || isValidGoDuration(value) || isBytesString(value);
});
// Add only labels that are present in every line to unwrapLabels
return possibleUnwrapLabels.filter((label) => labelsArray.every((obj) => obj[label]));
}
export function extractHasErrorLabelFromDataFrame(frame: DataFrame): boolean {
const labelField = frame.fields.find((field) => field.name === 'labels' && field.type === FieldType.other);
if (labelField == null) {
return false;
}
const labels: Array<{ [key: string]: string }> = labelField.values;
return labels.some((label) => label['__error__']);
}
export function extractLevelLikeLabelFromDataFrame(frame: DataFrame): string | null {
const labelField = frame.fields.find((field) => field.name === 'labels' && field.type === FieldType.other);
if (labelField == null) {
return null;
}
// Depending on number of labels, this can be pretty heavy operation.
// Let's just look at first 2 lines If needed, we can introduce more later.
const labelsArray: Array<{ [key: string]: string }> = labelField.values.slice(0, 2);
let levelLikeLabel: string | null = null;
// Find first level-like label
for (let labels of labelsArray) {
const label = Object.keys(labels).find((label) => label === 'lvl' || label.includes('level'));
if (label) {
levelLikeLabel = label;
break;
}
}
return levelLikeLabel;
}
function shouldCombine(frame1: DataFrame, frame2: DataFrame): boolean {
if (frame1.refId !== frame2.refId) {
return false;
}
const frameType1 = frame1.meta?.type;
const frameType2 = frame2.meta?.type;
if (frameType1 !== frameType2) {
// we do not join things that have a different type
return false;
}
// metric range query data
if (frameType1 === DataFrameType.TimeSeriesMulti) {
const field1 = frame1.fields.find((f) => f.type === FieldType.number);
const field2 = frame2.fields.find((f) => f.type === FieldType.number);
if (field1 === undefined || field2 === undefined) {
// should never happen
return false;
}
return shallowCompare(field1.labels ?? {}, field2.labels ?? {});
}
// logs query data
// logs use a special attribute in the dataframe's "custom" section
// because we do not have a good "frametype" value for them yet.
const customType1 = frame1.meta?.custom?.frameType;
const customType2 = frame2.meta?.custom?.frameType;
if (customType1 === 'LabeledTimeValues' && customType2 === 'LabeledTimeValues') {
return true;
}
// should never reach here
return false;
}
export function combineResponses(currentResult: DataQueryResponse | null, newResult: DataQueryResponse) {
if (!currentResult) {
return cloneQueryResponse(newResult);
}
newResult.data.forEach((newFrame) => {
const currentFrame = currentResult.data.find((frame) => shouldCombine(frame, newFrame));
if (!currentFrame) {
currentResult.data.push(cloneDataFrame(newFrame));
return;
}
combineFrames(currentFrame, newFrame);
});
const mergedErrors = [...(currentResult.errors ?? []), ...(newResult.errors ?? [])];
// we make sure to have `.errors` as undefined, instead of empty-array
// when no errors.
if (mergedErrors.length > 0) {
currentResult.errors = mergedErrors;
}
// the `.error` attribute is obsolete now,
// but we have to maintain it, otherwise
// some grafana parts do not behave well.
// we just choose the old error, if it exists,
// otherwise the new error, if it exists.
const mergedError = currentResult.error ?? newResult.error;
if (mergedError != null) {
currentResult.error = mergedError;
}
const mergedTraceIds = [...(currentResult.traceIds ?? []), ...(newResult.traceIds ?? [])];
if (mergedTraceIds.length > 0) {
currentResult.traceIds = mergedTraceIds;
}
return currentResult;
}
function combineFrames(dest: DataFrame, source: DataFrame) {
const totalFields = dest.fields.length;
for (let i = 0; i < totalFields; i++) {
dest.fields[i].values = [].concat.apply(source.fields[i].values, dest.fields[i].values);
if (source.fields[i].nanos) {
const nanos: number[] = dest.fields[i].nanos?.slice() || [];
dest.fields[i].nanos = source.fields[i].nanos?.concat(nanos);
}
}
dest.length += source.length;
dest.meta = {
...dest.meta,
stats: getCombinedMetadataStats(dest.meta?.stats ?? [], source.meta?.stats ?? []),
};
}
const TOTAL_BYTES_STAT = 'Summary: total bytes processed';
function getCombinedMetadataStats(
destStats: QueryResultMetaStat[],
sourceStats: QueryResultMetaStat[]
): QueryResultMetaStat[] {
// in the current approach, we only handle a single stat
const destStat = destStats.find((s) => s.displayName === TOTAL_BYTES_STAT);
const sourceStat = sourceStats.find((s) => s.displayName === TOTAL_BYTES_STAT);
if (sourceStat != null && destStat != null) {
return [{ value: sourceStat.value + destStat.value, displayName: TOTAL_BYTES_STAT, unit: destStat.unit }];
}
// maybe one of them exist
const eitherStat = sourceStat ?? destStat;
if (eitherStat != null) {
return [eitherStat];
}
return [];
}
/**
* Deep clones a DataQueryResponse
*/
export function cloneQueryResponse(response: DataQueryResponse): DataQueryResponse {
const newResponse = {
...response,
data: response.data.map(cloneDataFrame),
};
return newResponse;
}
function cloneDataFrame(frame: DataQueryResponseData): DataQueryResponseData {
return {
...frame,
fields: frame.fields.map((field: Field) => ({
...field,
values: field.values,
})),
};
}

@ -0,0 +1,281 @@
import { Grammar } from 'prismjs';
import { CompletionItem } from '@grafana/ui';
export const AGGREGATION_OPERATORS: CompletionItem[] = [
{
label: 'avg',
insertText: 'avg',
documentation: 'Calculate the average over dimensions',
},
{
label: 'bottomk',
insertText: 'bottomk',
documentation: 'Smallest k elements by sample value',
},
{
label: 'count',
insertText: 'count',
documentation: 'Count number of elements in the vector',
},
{
label: 'max',
insertText: 'max',
documentation: 'Select maximum over dimensions',
},
{
label: 'min',
insertText: 'min',
documentation: 'Select minimum over dimensions',
},
{
label: 'stddev',
insertText: 'stddev',
documentation: 'Calculate population standard deviation over dimensions',
},
{
label: 'stdvar',
insertText: 'stdvar',
documentation: 'Calculate population standard variance over dimensions',
},
{
label: 'sum',
insertText: 'sum',
documentation: 'Calculate sum over dimensions',
},
{
label: 'topk',
insertText: 'topk',
documentation: 'Largest k elements by sample value',
},
];
export const PIPE_PARSERS: CompletionItem[] = [
{
label: 'json',
insertText: 'json',
documentation: 'Extracting labels from the log line using json parser.',
},
{
label: 'regexp',
insertText: 'regexp ""',
documentation: 'Extracting labels from the log line using regexp parser.',
move: -1,
},
{
label: 'logfmt',
insertText: 'logfmt',
documentation: 'Extracting labels from the log line using logfmt parser.',
},
{
label: 'pattern',
insertText: 'pattern',
documentation: 'Extracting labels from the log line using pattern parser. Only available in Loki 2.3+.',
},
{
label: 'unpack',
insertText: 'unpack',
detail: 'unpack identifier',
documentation:
'Parses a JSON log line, unpacking all embedded labels in the pack stage. A special property "_entry" will also be used to replace the original log line. Only available in Loki 2.2+.',
},
];
export const PIPE_OPERATORS: CompletionItem[] = [
{
label: 'unwrap',
insertText: 'unwrap',
detail: 'unwrap identifier',
documentation: 'Take labels and use the values as sample data for metric aggregations.',
},
{
label: 'label_format',
insertText: 'label_format',
documentation: 'Use to rename, modify or add labels. For example, | label_format foo=bar .',
},
{
label: 'line_format',
insertText: 'line_format',
documentation: 'Rewrites log line content. For example, | line_format "{{.query}} {{.duration}}" .',
},
];
export const RANGE_VEC_FUNCTIONS = [
{
insertText: 'avg_over_time',
label: 'avg_over_time',
detail: 'avg_over_time(range-vector)',
documentation: 'The average of all values in the specified interval.',
},
{
insertText: 'bytes_over_time',
label: 'bytes_over_time',
detail: 'bytes_over_time(range-vector)',
documentation: 'Counts the amount of bytes used by each log stream for a given range',
},
{
insertText: 'bytes_rate',
label: 'bytes_rate',
detail: 'bytes_rate(range-vector)',
documentation: 'Calculates the number of bytes per second for each stream.',
},
{
insertText: 'first_over_time',
label: 'first_over_time',
detail: 'first_over_time(range-vector)',
documentation: 'The first of all values in the specified interval. Only available in Loki 2.3+.',
},
{
insertText: 'last_over_time',
label: 'last_over_time',
detail: 'last_over_time(range-vector)',
documentation: 'The last of all values in the specified interval. Only available in Loki 2.3+.',
},
{
insertText: 'sum_over_time',
label: 'sum_over_time',
detail: 'sum_over_time(range-vector)',
documentation: 'The sum of all values in the specified interval.',
},
{
insertText: 'count_over_time',
label: 'count_over_time',
detail: 'count_over_time(range-vector)',
documentation: 'The count of all values in the specified interval.',
},
{
insertText: 'max_over_time',
label: 'max_over_time',
detail: 'max_over_time(range-vector)',
documentation: 'The maximum of all values in the specified interval.',
},
{
insertText: 'min_over_time',
label: 'min_over_time',
detail: 'min_over_time(range-vector)',
documentation: 'The minimum of all values in the specified interval.',
},
{
insertText: 'quantile_over_time',
label: 'quantile_over_time',
detail: 'quantile_over_time(scalar, range-vector)',
documentation: 'The φ-quantile (0 ≤ φ ≤ 1) of the values in the specified interval.',
},
{
insertText: 'rate',
label: 'rate',
detail: 'rate(v range-vector)',
documentation: 'Calculates the number of entries per second.',
},
{
insertText: 'stddev_over_time',
label: 'stddev_over_time',
detail: 'stddev_over_time(range-vector)',
documentation: 'The population standard deviation of the values in the specified interval.',
},
{
insertText: 'stdvar_over_time',
label: 'stdvar_over_time',
detail: 'stdvar_over_time(range-vector)',
documentation: 'The population standard variance of the values in the specified interval.',
},
];
export const BUILT_IN_FUNCTIONS = [
{
insertText: 'vector',
label: 'vector',
detail: 'vector(scalar)',
documentation: 'Returns the scalar as a vector with no labels.',
},
];
export const FUNCTIONS = [...AGGREGATION_OPERATORS, ...RANGE_VEC_FUNCTIONS, ...BUILT_IN_FUNCTIONS];
// Loki grammar is used for query highlight in query previews outside of code editor
export const lokiGrammar: Grammar = {
comment: {
pattern: /#.*/,
},
'context-aggregation': {
pattern: /((without|by)\s*)\([^)]*\)/, // by ()
lookbehind: true,
inside: {
'label-key': {
pattern: /[^(),\s][^,)]*[^),\s]*/,
alias: 'attr-name',
},
punctuation: /[()]/,
},
},
'context-labels': {
pattern: /\{[^}]*(?=}?)/,
greedy: true,
inside: {
comment: {
pattern: /#.*/,
},
'label-key': {
pattern: /[a-zA-Z_]\w*(?=\s*(=|!=|=~|!~))/,
alias: 'attr-name',
greedy: true,
},
'label-value': {
pattern: /"(?:\\.|[^\\"])*"/,
greedy: true,
alias: 'attr-value',
},
punctuation: /[{]/,
},
},
'context-pipe': {
pattern: /\s\|[^=~]\s?\w*/i,
inside: {
'pipe-operator': {
pattern: /\|/i,
alias: 'operator',
},
'pipe-operations': {
pattern: new RegExp(`${[...PIPE_PARSERS, ...PIPE_OPERATORS].map((f) => f.label).join('|')}`, 'i'),
alias: 'keyword',
},
},
},
function: new RegExp(`\\b(?:${FUNCTIONS.map((f) => f.label).join('|')})(?=\\s*\\()`, 'i'),
'context-range': [
{
pattern: /\[[^\]]*(?=\])/, // [1m]
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
{
pattern: /(offset\s+)\w+/, // offset 1m
lookbehind: true,
inside: {
'range-duration': {
pattern: /\b\d+[smhdwy]\b/i,
alias: 'number',
},
},
},
],
quote: {
pattern: /"(?:\\.|[^\\"])*"/,
alias: 'string',
greedy: true,
},
backticks: {
pattern: /`(?:\\.|[^\\`])*`/,
alias: 'string',
greedy: true,
},
number: /\b-?\d+((\.\d*)?([eE][+-]?\d+)?)?\b/,
operator: /\s?(\|[=~]?|!=?|<(?:=>?|<|>)?|>[>=]?)\s?/i,
punctuation: /[{}(),.]/,
};
export default lokiGrammar;

@ -0,0 +1,97 @@
import { Observable } from 'rxjs';
import {
DataFrame,
DataQueryRequest,
DataQueryResponse,
DataSourceJsonData,
DataSourcePluginMeta,
DataSourceRef,
ScopedVars,
TestDataSourceResponse,
} from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
import LokiLanguageProvider from './LanguageProvider';
import { Loki as LokiQueryFromSchema, LokiQueryType, SupportingQueryType, LokiQueryDirection } from './dataquery.gen';
export { LokiQueryType };
export enum LokiResultType {
Stream = 'streams',
Vector = 'vector',
Matrix = 'matrix',
}
export interface LokiQuery extends LokiQueryFromSchema {
direction?: LokiQueryDirection;
/** Used only to identify supporting queries, e.g. logs volume, logs sample and data sample */
supportingQueryType?: SupportingQueryType;
// CUE autogenerates `queryType` as `?string`, as that's how it is defined
// in the parent-interface (in DataQuery).
// the temporary fix (until this gets improved in the codegen), is to
// override it here
queryType?: LokiQueryType;
/**
* This is a property for the experimental query splitting feature.
* @experimental
*/
splitDuration?: string;
}
export interface LokiOptions extends DataSourceJsonData {
maxLines?: string;
derivedFields?: DerivedFieldConfig[];
alertmanager?: string;
keepCookies?: string[];
predefinedOperations?: string;
}
export type DerivedFieldConfig = {
matcherRegex: string;
name: string;
url?: string;
urlDisplayLabel?: string;
datasourceUid?: string;
matcherType?: 'label' | 'regex';
};
export interface QueryStats {
streams: number;
chunks: number;
bytes: number;
entries: number;
// The error message displayed in the UI when we cant estimate the size of the query.
message?: string;
}
export type LokiDatasource = {
name: string;
id: number;
type: string;
uid: string;
query: (request: DataQueryRequest<any>) => Observable<DataQueryResponse> | Promise<DataQueryResponse>;
testDatasource: () => Promise<TestDataSourceResponse>;
meta: DataSourcePluginMeta<{}>;
getRef: () => DataSourceRef;
metadataRequest: (
url: string,
params?: Record<string, string | number>,
options?: Partial<BackendSrvRequest>
) => Promise<any>;
getTimeRangeParams: () => any;
interpolateString: (string: string, scopedVars?: ScopedVars) => string;
getDataSamples: (query: LokiQuery) => Promise<DataFrame[]>;
languageProvider: any;
};
export interface ParserAndLabelKeysResult {
extractedLabelKeys: string[];
hasJSON: boolean;
hasLogfmt: boolean;
hasPack: boolean;
unwrapLabelKeys: string[];
}
export type LanguageProvider = LokiLanguageProvider;

@ -0,0 +1,115 @@
import { css } from '@emotion/css';
import React from 'react';
import { useToggle } from 'react-use';
import { getValueFormat, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Collapse, Icon, Tooltip, useStyles2, Stack } from '@grafana/ui';
import { QueryStats } from '../loki/types';
export interface Props {
title: string;
collapsedInfo: string[];
queryStats?: QueryStats | null;
children: React.ReactNode;
}
export function QueryOptionGroup({ title, children, collapsedInfo, queryStats }: Props) {
const [isOpen, toggleOpen] = useToggle(false);
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<Collapse
className={styles.collapse}
collapsible
isOpen={isOpen}
onToggle={toggleOpen}
label={
<Stack gap={0}>
<h6 className={styles.title}>{title}</h6>
{!isOpen && (
<div className={styles.description}>
{collapsedInfo.map((x, i) => (
<span key={i}>{x}</span>
))}
</div>
)}
</Stack>
}
>
<div className={styles.body}>{children}</div>
</Collapse>
{queryStats && config.featureToggles.lokiQuerySplitting && (
<Tooltip content="Note: the query will be split into multiple parts and executed in sequence. Query limits will only apply each individual part.">
<Icon tabIndex={0} name="info-circle" className={styles.tooltip} size="sm" />
</Tooltip>
)}
{queryStats && <p className={styles.stats}>{generateQueryStats(queryStats)}</p>}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
collapse: css({
backgroundColor: 'unset',
border: 'unset',
marginBottom: 0,
['> button']: {
padding: theme.spacing(0, 1),
},
}),
wrapper: css({
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
}),
title: css({
flexGrow: 1,
overflow: 'hidden',
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.fontWeightMedium,
margin: 0,
}),
description: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
fontWeight: theme.typography.bodySmall.fontWeight,
paddingLeft: theme.spacing(2),
gap: theme.spacing(2),
display: 'flex',
}),
body: css({
display: 'flex',
gap: theme.spacing(2),
flexWrap: 'wrap',
}),
stats: css({
margin: '0px',
color: theme.colors.text.secondary,
fontSize: theme.typography.bodySmall.fontSize,
}),
tooltip: css({
marginRight: theme.spacing(0.25),
}),
};
};
const generateQueryStats = (queryStats: QueryStats) => {
if (queryStats.message) {
return queryStats.message;
}
return `This query will process approximately ${convertUnits(queryStats)}.`;
};
const convertUnits = (queryStats: QueryStats): string => {
const { text, suffix } = getValueFormat('bytes')(queryStats.bytes, 1);
return text + suffix;
};

@ -0,0 +1,37 @@
import { css, cx } from '@emotion/css';
import Prism, { Grammar } from 'prismjs';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data/src';
import { useTheme2 } from '@grafana/ui/src';
export interface Props {
query: string;
lang: {
grammar: Grammar;
name: string;
};
className?: string;
}
export function RawQuery({ query, lang, className }: Props) {
const theme = useTheme2();
const styles = getStyles(theme);
const highlighted = Prism.highlight(query, lang.grammar, lang.name);
return (
<div
className={cx(styles.editorField, 'prism-syntax-highlight', className)}
aria-label="selector"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
editorField: css({
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
}),
};
};

@ -0,0 +1,54 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTSTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export enum QueryEditorMode {
Builder = 'builder',
Code = 'code',
}
export type PromQueryFormat = ('time_series' | 'table' | 'heatmap');
export interface Prometheus extends common.DataQuery {
/**
* Specifies which editor is being used to prepare the query. It can be "code" or "builder"
*/
editorMode?: QueryEditorMode;
/**
* Execute an additional query to identify interesting raw samples relevant for the given expr
*/
exemplar?: boolean;
/**
* The actual expression/query that will be evaluated by Prometheus
*/
expr: string;
/**
* Query format to determine how to display data points in panel. It can be "time_series", "table", "heatmap"
*/
format?: PromQueryFormat;
/**
* Returns only the latest value that Prometheus has scraped for the requested time series
*/
instant?: boolean;
/**
* @deprecated Used to specify how many times to divide max data points by. We use max data points under query options
* See https://github.com/grafana/grafana/issues/48081
*/
intervalFactor?: number;
/**
* Series name override or template. Ex. {{hostname}} will be replaced with label value for hostname
*/
legendFormat?: string;
/**
* Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series
*/
range?: boolean;
}

@ -0,0 +1,122 @@
import { invert } from 'lodash';
import { Token } from 'prismjs';
import { AbstractLabelOperator, AbstractLabelMatcher, AbstractQuery } from '@grafana/data';
export const SUGGESTIONS_LIMIT = 10000;
const FromPromLikeMap: Record<string, AbstractLabelOperator> = {
'=': AbstractLabelOperator.Equal,
'!=': AbstractLabelOperator.NotEqual,
'=~': AbstractLabelOperator.EqualRegEx,
'!~': AbstractLabelOperator.NotEqualRegEx,
};
const ToPromLikeMap: Record<AbstractLabelOperator, string> = invert(FromPromLikeMap) as Record<
AbstractLabelOperator,
string
>;
export function limitSuggestions(items: string[]) {
return items.slice(0, SUGGESTIONS_LIMIT);
}
export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) {
// For processing we are going to use sets as they have significantly better performance than arrays
// After we process labels, we will convert sets to arrays and return object with label values in arrays
const valueSet: { [key: string]: Set<string> } = {};
labels.forEach((label) => {
const { __name__, ...rest } = label;
if (withName) {
valueSet['__name__'] = valueSet['__name__'] || new Set();
if (!valueSet['__name__'].has(__name__)) {
valueSet['__name__'].add(__name__);
}
}
Object.keys(rest).forEach((key) => {
if (!valueSet[key]) {
valueSet[key] = new Set();
}
if (!valueSet[key].has(rest[key])) {
valueSet[key].add(rest[key]);
}
});
});
// valueArray that we are going to return in the object
const valueArray: { [key: string]: string[] } = {};
limitSuggestions(Object.keys(valueSet)).forEach((key) => {
valueArray[key] = limitSuggestions(Array.from(valueSet[key]));
});
return { values: valueArray, keys: Object.keys(valueArray) };
}
export function toPromLikeExpr(labelBasedQuery: AbstractQuery): string {
const expr = labelBasedQuery.labelMatchers
.map((selector: AbstractLabelMatcher) => {
const operator = ToPromLikeMap[selector.operator];
if (operator) {
return `${selector.name}${operator}"${selector.value}"`;
} else {
return '';
}
})
.filter((e: string) => e !== '')
.join(', ');
return expr ? `{${expr}}` : '';
}
function getMaybeTokenStringContent(token: Token): string {
if (typeof token.content === 'string') {
return token.content;
}
return '';
}
export function extractLabelMatchers(tokens: Array<string | Token>): AbstractLabelMatcher[] {
const labelMatchers: AbstractLabelMatcher[] = [];
for (const token of tokens) {
if (!(token instanceof Token)) {
continue;
}
if (token.type === 'context-labels') {
let labelKey = '';
let labelValue = '';
let labelOperator = '';
const contentTokens = Array.isArray(token.content) ? token.content : [token.content];
for (let currentToken of contentTokens) {
if (typeof currentToken === 'string') {
let currentStr: string;
currentStr = currentToken;
if (currentStr === '=' || currentStr === '!=' || currentStr === '=~' || currentStr === '!~') {
labelOperator = currentStr;
}
} else if (currentToken instanceof Token) {
switch (currentToken.type) {
case 'label-key':
labelKey = getMaybeTokenStringContent(currentToken);
break;
case 'label-value':
labelValue = getMaybeTokenStringContent(currentToken);
labelValue = labelValue.substring(1, labelValue.length - 1);
const labelComparator = FromPromLikeMap[labelOperator];
if (labelComparator) {
labelMatchers.push({ name: labelKey, operator: labelComparator, value: labelValue });
}
break;
}
}
}
}
}
return labelMatchers;
}

@ -0,0 +1,165 @@
import { Observable } from 'rxjs';
import {
DataQueryRequest,
DataQueryResponse,
DataSourceGetTagKeysOptions,
DataSourceJsonData,
MetricFindValue,
} from '@grafana/data';
import { DataQuery } from '@grafana/schema';
import { Prometheus as GenPromQuery } from './dataquery.gen';
// import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types';
export interface QueryBuilderLabelFilter {
label: string;
op: string;
value: string;
}
export enum QueryEditorMode {
Code = 'code',
Builder = 'builder',
}
export interface PromQuery extends GenPromQuery, DataQuery {
/**
* Timezone offset to align start & end time on backend
*/
utcOffsetSec?: number;
valueWithRefId?: boolean;
showingGraph?: boolean;
showingTable?: boolean;
hinting?: boolean;
interval?: string;
// store the metrics explorer additional settings
useBackend?: boolean;
disableTextWrap?: boolean;
fullMetaSearch?: boolean;
includeNullMetadata?: boolean;
}
export enum PrometheusCacheLevel {
Low = 'Low',
Medium = 'Medium',
High = 'High',
None = 'None',
}
export enum PromApplication {
Cortex = 'Cortex',
Mimir = 'Mimir',
Prometheus = 'Prometheus',
Thanos = 'Thanos',
}
export interface PromOptions extends DataSourceJsonData {
timeInterval?: string;
queryTimeout?: string;
httpMethod?: string;
customQueryParameters?: string;
disableMetricsLookup?: boolean;
exemplarTraceIdDestinations?: ExemplarTraceIdDestination[];
prometheusType?: PromApplication;
prometheusVersion?: string;
cacheLevel?: PrometheusCacheLevel;
defaultEditor?: QueryEditorMode;
incrementalQuerying?: boolean;
incrementalQueryOverlapWindow?: string;
disableRecordingRules?: boolean;
sigV4Auth?: boolean;
oauthPassThru?: boolean;
}
export type ExemplarTraceIdDestination = {
name: string;
url?: string;
urlDisplayLabel?: string;
datasourceUid?: string;
};
export interface PromQueryRequest extends PromQuery {
step?: number;
requestId?: string;
start: number;
end: number;
headers?: any;
}
export interface PromMetricsMetadataItem {
type: string;
help: string;
unit?: string;
}
export interface PromMetricsMetadata {
[metric: string]: PromMetricsMetadataItem;
}
export type PromValue = [number, any];
export interface PromMetric {
__name__?: string;
[index: string]: any;
}
export interface PromBuildInfoResponse {
data: {
application?: string;
version: string;
revision: string;
features?: {
ruler_config_api?: 'true' | 'false';
alertmanager_config_api?: 'true' | 'false';
query_sharding?: 'true' | 'false';
federated_rules?: 'true' | 'false';
};
[key: string]: unknown;
};
status: 'success';
}
/**
* Auto = query.legendFormat == '__auto'
* Verbose = query.legendFormat == null/undefined/''
* Custom query.legendFormat.length > 0 && query.legendFormat !== '__auto'
*/
export enum LegendFormatMode {
Auto = '__auto',
Verbose = '__verbose',
Custom = '__custom',
}
export enum PromVariableQueryType {
LabelNames,
LabelValues,
MetricNames,
VarQueryResult,
SeriesQuery,
ClassicQuery,
}
export interface PromVariableQuery extends DataQuery {
query?: string;
expr?: string;
qryType?: PromVariableQueryType;
label?: string;
metric?: string;
varQuery?: string;
seriesQuery?: string;
labelFilters?: QueryBuilderLabelFilter[];
match?: string;
classicQuery?: string;
}
export type StandardPromVariableQuery = {
query: string;
refId: string;
};
export type PrometheusDatasource = {
getTagKeys(options: DataSourceGetTagKeysOptions): Promise<MetricFindValue[]>;
query(request: DataQueryRequest<PromQuery>): Observable<DataQueryResponse>;
};

@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
type Props = {
description: string;
suffix: string;
feature: string;
};
export function ConfigDescriptionLink(props: Props) {
const { description, suffix, feature } = props;
const text = `Learn more about ${feature}`;
const styles = useStyles2(getStyles);
return (
<span className={styles.container}>
{description}
<a
aria-label={text}
href={`https://grafana.com/docs/grafana/next/datasources/${suffix}`}
rel="noreferrer"
target="_blank"
>
{text}
</a>
</span>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
color: theme.colors.text.secondary,
a: css({
color: theme.colors.text.link,
textDecoration: 'underline',
marginLeft: '5px',
'&:hover': {
textDecoration: 'none',
},
}),
}),
};
};

@ -0,0 +1,76 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
import { invalidTimeShiftError } from '../TraceToLogs/TraceToLogsSettings';
import { IntervalInput } from './IntervalInput';
describe('IntervalInput', () => {
const IntervalInputtWithProps = ({ val }: { val: string }) => {
const [value, setValue] = useState(val);
return (
<IntervalInput
label=""
tooltip=""
value={value}
disabled={false}
onChange={(v) => {
setValue(v);
}}
isInvalidError={invalidTimeShiftError}
/>
);
};
describe('validates time shift correctly', () => {
it('for previosuly saved invalid value', async () => {
render(<IntervalInputtWithProps val="77" />);
expect(screen.getByDisplayValue('77')).toBeInTheDocument();
expect(screen.getByText(invalidTimeShiftError)).toBeInTheDocument();
});
it('for previously saved empty value', async () => {
render(<IntervalInputtWithProps val="" />);
expect(screen.getByPlaceholderText('0')).toBeInTheDocument();
expect(screen.queryByText(invalidTimeShiftError)).not.toBeInTheDocument();
});
it('for empty (valid) value', async () => {
render(<IntervalInputtWithProps val="1ms" />);
await userEvent.clear(screen.getByDisplayValue('1ms'));
await waitFor(() => {
expect(screen.queryByText(invalidTimeShiftError)).not.toBeInTheDocument();
});
});
it('for valid value', async () => {
render(<IntervalInputtWithProps val="10ms" />);
expect(screen.queryByText(invalidTimeShiftError)).not.toBeInTheDocument();
const input = screen.getByDisplayValue('10ms');
await userEvent.clear(input);
await userEvent.type(input, '100s');
await waitFor(() => {
expect(screen.queryByText(invalidTimeShiftError)).not.toBeInTheDocument();
});
await userEvent.clear(input);
await userEvent.type(input, '-77ms');
await waitFor(() => {
expect(screen.queryByText(invalidTimeShiftError)).not.toBeInTheDocument();
});
});
it('for invalid value', async () => {
render(<IntervalInputtWithProps val="10ms" />);
const input = screen.getByDisplayValue('10ms');
await userEvent.clear(input);
await userEvent.type(input, 'abc');
await waitFor(() => {
expect(screen.queryByText(invalidTimeShiftError)).toBeInTheDocument();
});
});
});
});

@ -0,0 +1,69 @@
import React, { useState } from 'react';
import { useDebounce } from 'react-use';
import { InlineField, Input } from '@grafana/ui';
import { validateInterval, validateIntervalRegex } from './validation';
interface Props {
value: string;
onChange: (val: string) => void;
isInvalidError: string;
placeholder?: string;
width?: number;
ariaLabel?: string;
label?: string;
tooltip?: string;
disabled?: boolean;
validationRegex?: RegExp;
}
interface FieldProps {
labelWidth: number;
disabled: boolean;
invalid: boolean;
error: string;
label?: string;
tooltip?: string;
}
export const IntervalInput = (props: Props) => {
const validationRegex = props.validationRegex || validateIntervalRegex;
const [intervalIsInvalid, setIntervalIsInvalid] = useState(() => {
return props.value ? validateInterval(props.value, validationRegex) : false;
});
useDebounce(
() => {
setIntervalIsInvalid(validateInterval(props.value, validationRegex));
},
500,
[props.value]
);
const fieldProps: FieldProps = {
labelWidth: 26,
disabled: props.disabled ?? false,
invalid: intervalIsInvalid,
error: props.isInvalidError,
};
if (props.label) {
fieldProps.label = props.label;
fieldProps.tooltip = props.tooltip || '';
}
return (
<InlineField {...fieldProps}>
<Input
type="text"
placeholder={props.placeholder || '0'}
width={props.width || 40}
onChange={(e) => {
props.onChange(e.currentTarget.value);
}}
value={props.value}
aria-label={props.ariaLabel || 'interval input'}
/>
</InlineField>
);
};

@ -0,0 +1,28 @@
import { validateInterval, validateIntervalRegex } from './validation';
describe('Validation', () => {
it('should validate incorrect values correctly', () => {
expect(validateInterval('-', validateIntervalRegex)).toBeTruthy();
expect(validateInterval('1', validateIntervalRegex)).toBeTruthy();
expect(validateInterval('test', validateIntervalRegex)).toBeTruthy();
expect(validateInterval('1ds', validateIntervalRegex)).toBeTruthy();
expect(validateInterval('10Ms', validateIntervalRegex)).toBeTruthy();
expect(validateInterval('-9999999', validateIntervalRegex)).toBeTruthy();
});
it('should validate correct values correctly', () => {
expect(validateInterval('1y', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('1M', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('1w', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('1d', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('2h', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('4m', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('8s', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('80ms', validateIntervalRegex)).toBeFalsy();
expect(validateInterval('-80ms', validateIntervalRegex)).toBeFalsy();
});
it('should not return error if no value provided', () => {
expect(validateInterval('', validateIntervalRegex)).toBeFalsy();
});
});

@ -0,0 +1,6 @@
export const validateIntervalRegex = /^(-?\d+(?:\.\d+)?)(ms|[Mwdhmsy])$/;
export const validateInterval = (val: string, regex: RegExp) => {
const matches = val.match(regex);
return matches || !val ? false : true;
};

@ -0,0 +1,112 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { InlineLabel, SegmentInput, ToolbarButton, useStyles2 } from '@grafana/ui';
import { ToolbarButtonVariant } from '@grafana/ui/src/components/ToolbarButton';
import { TraceToLogsTag } from './TraceToLogsSettings';
interface Props {
values: TraceToLogsTag[];
onChange: (values: TraceToLogsTag[]) => void;
id?: string;
}
const VARIANT = 'none' as ToolbarButtonVariant;
export const TagMappingInput = ({ values, onChange, id }: Props) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
{values.length ? (
values.map((value, idx) => (
<div className={styles.pair} key={idx}>
<SegmentInput
id={`${id}-key-${idx}`}
placeholder={'Tag name'}
value={value.key}
onChange={(e) => {
onChange(
values.map((v, i) => {
if (i === idx) {
return { ...v, key: String(e) };
}
return v;
})
);
}}
/>
<InlineLabel aria-label="equals" className={styles.operator}>
as
</InlineLabel>
<SegmentInput
id={`${id}-value-${idx}`}
placeholder={'New name (optional)'}
value={value.value || ''}
onChange={(e) => {
onChange(
values.map((v, i) => {
if (i === idx) {
return { ...v, value: String(e) };
}
return v;
})
);
}}
/>
<ToolbarButton
onClick={() => onChange([...values.slice(0, idx), ...values.slice(idx + 1)])}
className={cx(styles.removeTag, 'query-part')}
aria-label="Remove tag"
variant={VARIANT}
type="button"
icon="times"
/>
{idx === values.length - 1 ? (
<ToolbarButton
onClick={() => onChange([...values, { key: '', value: '' }])}
className="query-part"
aria-label="Add tag"
type="button"
variant={VARIANT}
icon="plus"
/>
) : null}
</div>
))
) : (
<ToolbarButton
icon="plus"
onClick={() => onChange([...values, { key: '', value: '' }])}
className="query-part"
aria-label="Add tag"
type="button"
variant={VARIANT}
/>
)}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: `${theme.spacing(0.5)} 0`,
}),
pair: css({
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
}),
operator: css({
color: theme.v1.palette.orange,
width: 'auto',
}),
removeTag: css({
marginRight: theme.spacing(0.5),
}),
});

@ -0,0 +1,123 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { DataSourceInstanceSettings, DataSourceSettings } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { TraceToLogsData, TraceToLogsSettings } from './TraceToLogsSettings';
const defaultOptionsOldFormat: DataSourceSettings<TraceToLogsData> = {
jsonData: {
tracesToLogs: {
datasourceUid: 'loki1_uid',
tags: ['someTag'],
mapTagNamesEnabled: false,
spanStartTimeShift: '1m',
spanEndTimeShift: '1m',
filterByTraceID: true,
filterBySpanID: true,
},
},
} as unknown as DataSourceSettings<TraceToLogsData>;
const defaultOptionsNewFormat: DataSourceSettings<TraceToLogsData> = {
jsonData: {
tracesToLogsV2: {
datasourceUid: 'loki1_uid',
tags: [{ key: 'someTag', value: 'newName' }],
spanStartTimeShift: '1m',
spanEndTimeShift: '1m',
filterByTraceID: true,
filterBySpanID: true,
customQuery: true,
query: '{${__tags}}',
},
},
} as unknown as DataSourceSettings<TraceToLogsData>;
const lokiSettings = {
uid: 'loki1_uid',
name: 'loki1',
type: 'loki',
meta: { info: { logos: { small: '' } } },
} as unknown as DataSourceInstanceSettings;
describe('TraceToLogsSettings', () => {
beforeAll(() => {
setDataSourceSrv({
getList() {
return [lokiSettings];
},
getInstanceSettings() {
return lokiSettings;
},
} as unknown as DataSourceSrv);
});
it('should render old format without error', () => {
expect(() =>
render(<TraceToLogsSettings options={defaultOptionsOldFormat} onOptionsChange={() => {}} />)
).not.toThrow();
});
it('should render new format without error', () => {
expect(() =>
render(<TraceToLogsSettings options={defaultOptionsNewFormat} onOptionsChange={() => {}} />)
).not.toThrow();
});
it('should render and transform data from old format correctly', () => {
render(<TraceToLogsSettings options={defaultOptionsOldFormat} onOptionsChange={() => {}} />);
expect(screen.getByText('someTag')).toBeInTheDocument();
expect((screen.getByLabelText('Use custom query') as HTMLInputElement).checked).toBeFalsy();
expect((screen.getByLabelText('Filter by trace ID') as HTMLInputElement).checked).toBeTruthy();
expect((screen.getByLabelText('Filter by span ID') as HTMLInputElement).checked).toBeTruthy();
});
it('renders old mapped tags correctly', () => {
const options = {
...defaultOptionsOldFormat,
jsonData: {
...defaultOptionsOldFormat.jsonData,
tracesToLogs: {
...defaultOptionsOldFormat.jsonData.tracesToLogs,
tags: undefined,
mappedTags: [{ key: 'someTag', value: 'withNewName' }],
mapTagNamesEnabled: true,
},
},
};
render(<TraceToLogsSettings options={options} onOptionsChange={() => {}} />);
expect(screen.getByText('someTag')).toBeInTheDocument();
expect(screen.getByText('withNewName')).toBeInTheDocument();
});
it('transforms old format to new on change', async () => {
const changeMock = jest.fn();
render(<TraceToLogsSettings options={defaultOptionsOldFormat} onOptionsChange={changeMock} />);
const checkBox = screen.getByLabelText('Filter by trace ID');
await userEvent.click(checkBox);
expect(changeMock.mock.calls[0]).toEqual([
{
jsonData: {
tracesToLogs: undefined,
tracesToLogsV2: {
customQuery: false,
datasourceUid: 'loki1_uid',
filterBySpanID: true,
filterByTraceID: false,
spanEndTimeShift: '1m',
spanStartTimeShift: '1m',
tags: [
{
key: 'someTag',
},
],
},
},
},
]);
});
});

@ -0,0 +1,275 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo } from 'react';
import { DataSourceJsonData, DataSourceInstanceSettings, DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ConfigSection } from '@grafana/experimental';
import { DataSourcePicker } from '@grafana/runtime';
import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui';
import { ConfigDescriptionLink } from '../ConfigDescriptionLink';
import { IntervalInput } from '../IntervalInput/IntervalInput';
import { TagMappingInput } from './TagMappingInput';
export interface TraceToLogsTag {
key: string;
value?: string;
}
// @deprecated use getTraceToLogsOptions to get the v2 version of this config from jsonData
export interface TraceToLogsOptions {
datasourceUid?: string;
tags?: string[];
mappedTags?: TraceToLogsTag[];
mapTagNamesEnabled?: boolean;
spanStartTimeShift?: string;
spanEndTimeShift?: string;
filterByTraceID?: boolean;
filterBySpanID?: boolean;
lokiSearch?: boolean; // legacy
}
export interface TraceToLogsOptionsV2 {
datasourceUid?: string;
tags?: TraceToLogsTag[];
spanStartTimeShift?: string;
spanEndTimeShift?: string;
filterByTraceID?: boolean;
filterBySpanID?: boolean;
query?: string;
customQuery: boolean;
}
export interface TraceToLogsData extends DataSourceJsonData {
tracesToLogs?: TraceToLogsOptions;
tracesToLogsV2?: TraceToLogsOptionsV2;
}
/**
* Gets new version of the traceToLogs config from the json data either returning directly or transforming the old
* version to new and returning that.
*/
export function getTraceToLogsOptions(data?: TraceToLogsData): TraceToLogsOptionsV2 | undefined {
if (data?.tracesToLogsV2) {
return data.tracesToLogsV2;
}
if (!data?.tracesToLogs) {
return undefined;
}
const traceToLogs: TraceToLogsOptionsV2 = {
customQuery: false,
};
traceToLogs.datasourceUid = data.tracesToLogs.datasourceUid;
traceToLogs.tags = data.tracesToLogs.mapTagNamesEnabled
? data.tracesToLogs.mappedTags
: data.tracesToLogs.tags?.map((tag) => ({ key: tag }));
traceToLogs.filterByTraceID = data.tracesToLogs.filterByTraceID;
traceToLogs.filterBySpanID = data.tracesToLogs.filterBySpanID;
traceToLogs.spanStartTimeShift = data.tracesToLogs.spanStartTimeShift;
traceToLogs.spanEndTimeShift = data.tracesToLogs.spanEndTimeShift;
return traceToLogs;
}
interface Props extends DataSourcePluginOptionsEditorProps<TraceToLogsData> {}
export function TraceToLogsSettings({ options, onOptionsChange }: Props) {
const supportedDataSourceTypes = [
'loki',
'elasticsearch',
'grafana-splunk-datasource', // external
'grafana-opensearch-datasource', // external
'grafana-falconlogscale-datasource', // external
'googlecloud-logging-datasource', // external
];
const traceToLogs = useMemo(
(): TraceToLogsOptionsV2 => getTraceToLogsOptions(options.jsonData) || { customQuery: false },
[options.jsonData]
);
const { query = '', tags, customQuery } = traceToLogs;
const updateTracesToLogs = useCallback(
(value: Partial<TraceToLogsOptionsV2>) => {
// Cannot use updateDatasourcePluginJsonDataOption here as we need to update 2 keys, and they would overwrite each
// other as updateDatasourcePluginJsonDataOption isn't synchronized
onOptionsChange({
...options,
jsonData: {
...options.jsonData,
tracesToLogsV2: {
...traceToLogs,
...value,
},
tracesToLogs: undefined,
},
});
},
[onOptionsChange, options, traceToLogs]
);
return (
<div className={css({ width: '100%' })}>
<InlineFieldRow>
<InlineField
tooltip="The logs data source the trace is going to navigate to"
label="Data source"
labelWidth={26}
>
<DataSourcePicker
inputId="trace-to-logs-data-source-picker"
filter={(ds) => supportedDataSourceTypes.includes(ds.type)}
current={traceToLogs.datasourceUid}
noDefault={true}
width={40}
onChange={(ds: DataSourceInstanceSettings) =>
updateTracesToLogs({
datasourceUid: ds.uid,
})
}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<IntervalInput
label={getTimeShiftLabel('start')}
tooltip={getTimeShiftTooltip('start', '0')}
value={traceToLogs.spanStartTimeShift || ''}
onChange={(val) => {
updateTracesToLogs({ spanStartTimeShift: val });
}}
isInvalidError={invalidTimeShiftError}
/>
</InlineFieldRow>
<InlineFieldRow>
<IntervalInput
label={getTimeShiftLabel('end')}
tooltip={getTimeShiftTooltip('end', '0')}
value={traceToLogs.spanEndTimeShift || ''}
onChange={(val) => {
updateTracesToLogs({ spanEndTimeShift: val });
}}
isInvalidError={invalidTimeShiftError}
/>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
tooltip="Tags that will be used in the query. Default tags: 'cluster', 'hostname', 'namespace', 'pod', 'service.name', 'service.namespace'"
label="Tags"
labelWidth={26}
>
<TagMappingInput values={tags ?? []} onChange={(v) => updateTracesToLogs({ tags: v })} />
</InlineField>
</InlineFieldRow>
<IdFilter
disabled={customQuery}
type={'trace'}
id={'filterByTraceID'}
value={Boolean(traceToLogs.filterByTraceID)}
onChange={(val) => updateTracesToLogs({ filterByTraceID: val })}
/>
<IdFilter
disabled={customQuery}
type={'span'}
id={'filterBySpanID'}
value={Boolean(traceToLogs.filterBySpanID)}
onChange={(val) => updateTracesToLogs({ filterBySpanID: val })}
/>
<InlineFieldRow>
<InlineField
tooltip="Use a custom query with the possibility to interpolate variables from the trace or span"
label="Use custom query"
labelWidth={26}
>
<InlineSwitch
id={'customQuerySwitch'}
value={customQuery}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) =>
updateTracesToLogs({ customQuery: event.currentTarget.checked })
}
/>
</InlineField>
</InlineFieldRow>
{customQuery && (
<InlineField
label="Query"
labelWidth={26}
tooltip="The query that will run when navigating from a trace to logs data source. Interpolate tags using the `$__tags` keyword"
grow
>
<Input
label="Query"
type="text"
allowFullScreen
value={query}
onChange={(e) => updateTracesToLogs({ query: e.currentTarget.value })}
/>
</InlineField>
)}
</div>
);
}
interface IdFilterProps {
type: 'trace' | 'span';
id: string;
value: boolean;
onChange: (val: boolean) => void;
disabled: boolean;
}
function IdFilter(props: IdFilterProps) {
return (
<InlineFieldRow>
<InlineField
disabled={props.disabled}
label={`Filter by ${props.type} ID`}
labelWidth={26}
grow
tooltip={`Filters logs by ${props.type} ID`}
>
<InlineSwitch
id={props.id}
value={props.value}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => props.onChange(event.currentTarget.checked)}
/>
</InlineField>
</InlineFieldRow>
);
}
export const getTimeShiftLabel = (type: 'start' | 'end') => {
return `Span ${type} time shift`;
};
export const getTimeShiftTooltip = (type: 'start' | 'end', defaultVal: string) => {
return `Shifts the ${type} time of the span. Default: ${defaultVal} (Time units can be used here, for example: 5s, -1m, 3h)`;
};
export const invalidTimeShiftError = 'Invalid time shift. See tooltip for examples.';
export const TraceToLogsSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => {
let suffix = options.type;
suffix += options.type === 'tempo' ? '/configure-tempo-data-source/#trace-to-logs' : '/#trace-to-logs';
return (
<ConfigSection
title="Trace to logs"
description={
<ConfigDescriptionLink
description="Navigate from a trace span to the selected data source's logs."
suffix={suffix}
feature="trace to logs"
/>
}
isCollapsible={true}
isInitiallyOpen={true}
>
<TraceToLogsSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
);
};

@ -0,0 +1,240 @@
import { css } from '@emotion/css';
import React from 'react';
import {
DataSourceInstanceSettings,
DataSourceJsonData,
DataSourcePluginOptionsEditorProps,
GrafanaTheme2,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { ConfigSection } from '@grafana/experimental';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, InlineFieldRow, Input, useStyles2 } from '@grafana/ui';
import { ConfigDescriptionLink } from '../ConfigDescriptionLink';
import { IntervalInput } from '../IntervalInput/IntervalInput';
import { TagMappingInput } from '../TraceToLogs/TagMappingInput';
import { getTimeShiftLabel, getTimeShiftTooltip, invalidTimeShiftError } from '../TraceToLogs/TraceToLogsSettings';
export interface TraceToMetricsOptions {
datasourceUid?: string;
tags?: Array<{ key: string; value: string }>;
queries: TraceToMetricQuery[];
spanStartTimeShift?: string;
spanEndTimeShift?: string;
}
export interface TraceToMetricQuery {
name?: string;
query?: string;
}
export interface TraceToMetricsData extends DataSourceJsonData {
tracesToMetrics?: TraceToMetricsOptions;
}
interface Props extends DataSourcePluginOptionsEditorProps<TraceToMetricsData> {}
export function TraceToMetricsSettings({ options, onOptionsChange }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={css({ width: '100%' })}>
<InlineFieldRow className={styles.row}>
<InlineField
tooltip="The Prometheus data source the trace is going to navigate to"
label="Data source"
labelWidth={26}
>
<DataSourcePicker
inputId="trace-to-metrics-data-source-picker"
pluginId="prometheus"
current={options.jsonData.tracesToMetrics?.datasourceUid}
noDefault={true}
width={40}
onChange={(ds: DataSourceInstanceSettings) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
datasourceUid: ds.uid,
})
}
/>
</InlineField>
{options.jsonData.tracesToMetrics?.datasourceUid ? (
<Button
type="button"
variant="secondary"
size="sm"
fill="text"
onClick={() => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
datasourceUid: undefined,
});
}}
>
Clear
</Button>
) : null}
</InlineFieldRow>
<InlineFieldRow>
<IntervalInput
label={getTimeShiftLabel('start')}
tooltip={getTimeShiftTooltip('start', '-2m')}
value={options.jsonData.tracesToMetrics?.spanStartTimeShift || ''}
onChange={(val) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
spanStartTimeShift: val,
});
}}
placeholder={'-2m'}
isInvalidError={invalidTimeShiftError}
/>
</InlineFieldRow>
<InlineFieldRow>
<IntervalInput
label={getTimeShiftLabel('end')}
tooltip={getTimeShiftTooltip('end', '2m')}
value={options.jsonData.tracesToMetrics?.spanEndTimeShift || ''}
onChange={(val) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
spanEndTimeShift: val,
});
}}
placeholder={'2m'}
isInvalidError={invalidTimeShiftError}
/>
</InlineFieldRow>
<InlineFieldRow>
<InlineField tooltip="Tags that will be used in the metrics query" label="Tags" labelWidth={26}>
<TagMappingInput
values={options.jsonData.tracesToMetrics?.tags ?? []}
onChange={(v) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
tags: v,
})
}
/>
</InlineField>
</InlineFieldRow>
{options.jsonData.tracesToMetrics?.queries?.map((query, i) => (
<div key={i} className={styles.queryRow}>
<InlineField label="Link Label" labelWidth={26} tooltip="Descriptive label for the linked query">
<Input
label="Link Label"
type="text"
allowFullScreen
value={query.name}
width={40}
onChange={(e) => {
let newQueries = options.jsonData.tracesToMetrics?.queries.slice() ?? [];
newQueries[i].name = e.currentTarget.value;
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
queries: newQueries,
});
}}
/>
</InlineField>
<InlineField
label="Query"
labelWidth={10}
tooltip="The Prometheus query that will run when navigating from a trace to metrics. Interpolate tags using the `$__tags` keyword"
grow
>
<Input
label="Query"
type="text"
allowFullScreen
value={query.query}
onChange={(e) => {
let newQueries = options.jsonData.tracesToMetrics?.queries.slice() ?? [];
newQueries[i].query = e.currentTarget.value;
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
queries: newQueries,
});
}}
/>
</InlineField>
<Button
variant="destructive"
title="Remove query"
icon="times"
type="button"
onClick={() => {
let newQueries = options.jsonData.tracesToMetrics?.queries.slice();
newQueries?.splice(i, 1);
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
queries: newQueries,
});
}}
/>
</div>
))}
<Button
variant="secondary"
title="Add query"
icon="plus"
type="button"
onClick={() => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToMetrics', {
...options.jsonData.tracesToMetrics,
queries: [...(options.jsonData.tracesToMetrics?.queries ?? []), { query: '' }],
});
}}
>
Add query
</Button>
</div>
);
}
export const TraceToMetricsSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => {
let suffix = options.type;
suffix += options.type === 'tempo' ? '/configure-tempo-data-source/#trace-to-metrics' : '/#trace-to-metrics';
return (
<ConfigSection
title="Trace to metrics"
description={
<ConfigDescriptionLink
description="Navigate from a trace span to the selected data source's metrics."
suffix={suffix}
feature="trace to metrics"
/>
}
isCollapsible={true}
isInitiallyOpen={true}
>
<TraceToMetricsSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
infoText: css`
padding-bottom: ${theme.spacing(2)};
color: ${theme.colors.text.secondary};
`,
row: css`
label: row;
align-items: baseline;
`,
queryRow: css`
label: queryRow;
display: flex;
flex-flow: wrap;
`,
});

@ -0,0 +1,53 @@
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { DataSourceInstanceSettings, DataSourceSettings } from '@grafana/data';
import { DataSourceSrv, setDataSourceSrv } from '@grafana/runtime';
import { TraceToProfilesData, TraceToProfilesSettings } from './TraceToProfilesSettings';
const defaultOption: DataSourceSettings<TraceToProfilesData> = {
jsonData: {
tracesToProfiles: {
datasourceUid: 'profiling1_uid',
tags: [{ key: 'someTag', value: 'newName' }],
customQuery: true,
query: '{${__tags}}',
},
},
} as unknown as DataSourceSettings<TraceToProfilesData>;
const pyroSettings = {
uid: 'profiling1_uid',
name: 'profiling1',
type: 'grafana-pyroscope-datasource',
meta: { info: { logos: { small: '' } } },
} as unknown as DataSourceInstanceSettings;
describe('TraceToProfilesSettings', () => {
beforeAll(() => {
setDataSourceSrv({
getList() {
return [pyroSettings];
},
getInstanceSettings() {
return pyroSettings;
},
} as unknown as DataSourceSrv);
});
it('should render without error', () => {
waitFor(() => {
expect(() =>
render(<TraceToProfilesSettings options={defaultOption} onOptionsChange={() => {}} />)
).not.toThrow();
});
});
it('should render all options', () => {
render(<TraceToProfilesSettings options={defaultOption} onOptionsChange={() => {}} />);
expect(screen.getByText('Tags')).toBeInTheDocument();
expect(screen.getByText('Profile type')).toBeInTheDocument();
expect(screen.getByText('Use custom query')).toBeInTheDocument();
});
});

@ -0,0 +1,186 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import {
DataSourceJsonData,
DataSourceInstanceSettings,
DataSourcePluginOptionsEditorProps,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { ConfigSection } from '@grafana/experimental';
import { DataSourcePicker, getDataSourceSrv } from '@grafana/runtime';
import { InlineField, InlineFieldRow, Input, InlineSwitch } from '@grafana/ui';
import { ConfigDescriptionLink } from '../ConfigDescriptionLink';
import { TagMappingInput } from '../TraceToLogs/TagMappingInput';
import { ProfileTypesCascader } from '../pyroscope/ProfileTypesCascader';
import { PyroscopeDataSource } from '../pyroscope/datasource';
import { ProfileTypeMessage } from '../pyroscope/types';
export interface TraceToProfilesOptions {
datasourceUid?: string;
tags?: Array<{ key: string; value?: string }>;
query?: string;
profileTypeId?: string;
customQuery: boolean;
}
export interface TraceToProfilesData extends DataSourceJsonData {
tracesToProfiles?: TraceToProfilesOptions;
}
interface Props extends DataSourcePluginOptionsEditorProps<TraceToProfilesData> {}
export function TraceToProfilesSettings({ options, onOptionsChange }: Props) {
const supportedDataSourceTypes = useMemo(() => ['grafana-pyroscope-datasource'], []);
const [profileTypes, setProfileTypes] = useState<ProfileTypeMessage[]>([]);
const profileTypesPlaceholder = useMemo(() => {
let placeholder = profileTypes.length === 0 ? 'No profile types found' : 'Select profile type';
if (!options.jsonData.tracesToProfiles?.datasourceUid) {
placeholder = 'Please select profiling data source';
}
return placeholder;
}, [options.jsonData.tracesToProfiles?.datasourceUid, profileTypes]);
const { value: dataSource } = useAsync(async () => {
return await getDataSourceSrv().get(options.jsonData.tracesToProfiles?.datasourceUid);
}, [options.jsonData.tracesToProfiles?.datasourceUid]);
useEffect(() => {
if (
dataSource &&
dataSource instanceof PyroscopeDataSource &&
supportedDataSourceTypes.includes(dataSource.type) &&
dataSource.uid === options.jsonData.tracesToProfiles?.datasourceUid
) {
dataSource.getProfileTypes().then((profileTypes) => {
setProfileTypes(profileTypes);
});
} else {
setProfileTypes([]);
}
}, [dataSource, onOptionsChange, options, supportedDataSourceTypes]);
return (
<div className={css({ width: '100%' })}>
<InlineFieldRow>
<InlineField
tooltip="The profiles data source the trace is going to navigate to"
label="Data source"
labelWidth={26}
>
<DataSourcePicker
inputId="trace-to-profiles-data-source-picker"
filter={(ds) => supportedDataSourceTypes.includes(ds.type)}
current={options.jsonData.tracesToProfiles?.datasourceUid}
noDefault={true}
width={40}
onChange={(ds: DataSourceInstanceSettings) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToProfiles', {
...options.jsonData.tracesToProfiles,
datasourceUid: ds.uid,
});
}}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
tooltip="Tags that will be used in the query. Default tags: 'service.name', 'service.namespace'"
label="Tags"
labelWidth={26}
>
<TagMappingInput
values={options.jsonData.tracesToProfiles?.tags ?? []}
onChange={(v) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToProfiles', {
...options.jsonData.tracesToProfiles,
tags: v,
});
}}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField tooltip="Profile type that will be used in the query" label="Profile type" labelWidth={26}>
<ProfileTypesCascader
profileTypes={profileTypes}
placeholder={profileTypesPlaceholder}
initialProfileTypeId={options.jsonData.tracesToProfiles?.profileTypeId}
onChange={(val) => {
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToProfiles', {
...options.jsonData.tracesToProfiles,
profileTypeId: val,
});
}}
width={40}
/>
</InlineField>
</InlineFieldRow>
<InlineFieldRow>
<InlineField
tooltip="Use a custom query with the possibility to interpolate variables from the trace or span"
label="Use custom query"
labelWidth={26}
>
<InlineSwitch
id={'profilesCustomQuerySwitch'}
value={options.jsonData.tracesToProfiles?.customQuery}
onChange={(event: React.SyntheticEvent<HTMLInputElement>) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToProfiles', {
...options.jsonData.tracesToProfiles,
customQuery: event.currentTarget.checked,
})
}
/>
</InlineField>
</InlineFieldRow>
{options.jsonData.tracesToProfiles?.customQuery && (
<InlineField
label="Query"
labelWidth={26}
tooltip="The query that will run when navigating from a trace to profiles data source. Interpolate tags using the `$__tags` keyword"
grow
>
<Input
label="Query"
type="text"
allowFullScreen
value={options.jsonData.tracesToProfiles?.query || ''}
onChange={(e) =>
updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'tracesToProfiles', {
...options.jsonData.tracesToProfiles,
query: e.currentTarget.value,
})
}
/>
</InlineField>
)}
</div>
);
}
export const TraceToProfilesSection = ({ options, onOptionsChange }: DataSourcePluginOptionsEditorProps) => {
return (
<ConfigSection
title="Trace to profiles"
description={
<ConfigDescriptionLink
description="Navigate from a trace span to the selected data source's profiles."
suffix={`${options.type}/#trace-to-profiles`}
feature="trace to profiles"
/>
}
isCollapsible={true}
isInitiallyOpen={true}
>
<TraceToProfilesSettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
);
};

@ -0,0 +1,8 @@
/**
* A library containing logic to manage traces.
*
* @packageDocumentation
*/
type Props = {};
export { Props };

@ -0,0 +1,12 @@
/**
* A library containing logic to manage traces.
*
* @packageDocumentation
*/
export * from './IntervalInput/IntervalInput';
export * from './TraceToLogs/TagMappingInput';
export * from './TraceToLogs/TraceToLogsSettings';
export * from './TraceToMetrics/TraceToMetricsSettings';
export * from './TraceToProfiles/TraceToProfilesSettings';
export * from './utils';

@ -0,0 +1,85 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Cascader, CascaderOption } from '@grafana/ui';
import { PyroscopeDataSource } from './datasource';
import { ProfileTypeMessage } from './types';
type Props = {
initialProfileTypeId?: string;
profileTypes?: ProfileTypeMessage[];
onChange: (value: string) => void;
placeholder?: string;
width?: number;
};
export function ProfileTypesCascader(props: Props) {
const cascaderOptions = useCascaderOptions(props.profileTypes);
return (
<Cascader
placeholder={props.placeholder}
separator={'-'}
displayAllSelectedLevels={true}
initialValue={props.initialProfileTypeId}
allowCustomValue={true}
onSelect={props.onChange}
options={cascaderOptions}
changeOnSelect={false}
width={props.width ?? 26}
/>
);
}
// Turn profileTypes into cascader options
function useCascaderOptions(profileTypes?: ProfileTypeMessage[]): CascaderOption[] {
return useMemo(() => {
if (!profileTypes) {
return [];
}
let mainTypes = new Map<string, CascaderOption>();
// Classify profile types by name then sample type.
// The profileTypes are something like cpu:sample:nanoseconds:sample:count or app.something.something
for (let profileType of profileTypes) {
let parts: string[] = [];
if (profileType.id.indexOf(':') > -1) {
parts = profileType.id.split(':');
}
const [name, type] = parts;
if (!mainTypes.has(name)) {
mainTypes.set(name, {
label: name,
value: name,
items: [],
});
}
mainTypes.get(name)?.items!.push({
label: type,
value: profileType.id,
});
}
return Array.from(mainTypes.values());
}, [profileTypes]);
}
/**
* Loads the profile types.
*
* This is exported and not used directly in the ProfileTypesCascader component because in some case we need to know
* the profileTypes before rendering the cascader.
* @param datasource
*/
export function useProfileTypes(datasource: PyroscopeDataSource) {
const [profileTypes, setProfileTypes] = useState<ProfileTypeMessage[]>();
useEffect(() => {
(async () => {
const profileTypes = await datasource.getProfileTypes();
setProfileTypes(profileTypes);
})();
}, [datasource]);
return profileTypes;
}

@ -0,0 +1,44 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTSTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export type PyroscopeQueryType = ('metrics' | 'profile' | 'both');
export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both';
export interface GrafanaPyroscope extends common.DataQuery {
/**
* Allows to group the results.
*/
groupBy: Array<string>;
/**
* Specifies the query label selectors.
*/
labelSelector: string;
/**
* Sets the maximum number of nodes in the flamegraph.
*/
maxNodes?: number;
/**
* Specifies the type of profile to query.
*/
profileTypeId: string;
/**
* Specifies the query span selectors.
*/
spanSelector?: Array<string>;
}
export const defaultGrafanaPyroscope: Partial<GrafanaPyroscope> = {
groupBy: [],
labelSelector: '{}',
spanSelector: [],
};

@ -0,0 +1,28 @@
import { Observable } from 'rxjs';
import { AbstractQuery, CoreApp, DataQueryRequest, DataQueryResponse, ScopedVars } from '@grafana/data';
import { DataSourceWithBackend } from '@grafana/runtime';
import { PyroscopeDataSourceOptions, Query, ProfileTypeMessage } from './types';
export abstract class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeDataSourceOptions> {
abstract query(request: DataQueryRequest<Query>): Observable<DataQueryResponse>;
abstract getProfileTypes(): Promise<ProfileTypeMessage[]>;
abstract getLabelNames(query: string, start: number, end: number): Promise<string[]>;
abstract getLabelValues(query: string, label: string, start: number, end: number): Promise<string[]>;
abstract applyTemplateVariables(query: Query, scopedVars: ScopedVars): Query;
abstract importFromAbstractQueries(abstractQueries: AbstractQuery[]): Promise<Query[]>;
abstract importFromAbstractQuery(labelBasedQuery: AbstractQuery): Query;
abstract exportToAbstractQueries(queries: Query[]): Promise<AbstractQuery[]>;
abstract exportToAbstractQuery(query: Query): AbstractQuery;
abstract getDefaultQuery(app: CoreApp): Partial<Query>;
}

@ -0,0 +1,16 @@
import { DataSourceJsonData } from '@grafana/data';
import { GrafanaPyroscope, PyroscopeQueryType } from './dataquery.gen';
export interface ProfileTypeMessage {
id: string;
label: string;
}
export interface PyroscopeDataSourceOptions extends DataSourceJsonData {
minStep?: string;
}
export interface Query extends GrafanaPyroscope {
queryType: PyroscopeQueryType;
}

@ -0,0 +1,118 @@
/**
* Get non overlapping duration of the ranges as they can overlap or have gaps.
*/
import { FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
export function getNonOverlappingDuration(ranges: Array<[number, number]>): number {
ranges.sort((a, b) => a[0] - b[0]);
const mergedRanges = ranges.reduce<Array<[number, number]>>((acc, range) => {
if (!acc.length) {
return [range];
}
const tail = acc.slice(-1)[0];
const [prevStart, prevEnd] = tail;
const [start, end] = range;
if (end < prevEnd) {
// In this case the range is completely inside the prev range so we can just ignore it.
return acc;
}
if (start > prevEnd) {
// There is no overlap so we can just add it to stack
return [...acc, range];
}
// We know there is overlap and current range ends later than previous so we can just extend the range
return [...acc.slice(0, -1), [prevStart, end]];
}, []);
return mergedRanges.reduce((acc, range) => {
return acc + (range[1] - range[0]);
}, 0);
}
/**
* Returns a map of the spans with children array for easier processing. It will also contain empty spans in case
* span is missing but other spans are its children. This is more generic because it needs to allow iterating over
* both arrays and dataframe views.
*/
export function makeSpanMap<T>(getSpan: (index: number) => { span: T; id: string; parentIds: string[] } | undefined): {
[id: string]: { span: T; children: string[] };
} {
const spanMap: { [id: string]: { span?: T; children: string[] } } = {};
let span;
for (let index = 0; (span = getSpan(index)), !!span; index++) {
if (!spanMap[span.id]) {
spanMap[span.id] = {
span: span.span,
children: [],
};
} else {
spanMap[span.id].span = span.span;
}
for (const parentId of span.parentIds) {
if (parentId) {
if (!spanMap[parentId]) {
spanMap[parentId] = {
span: undefined,
children: [span.id],
};
} else {
spanMap[parentId].children.push(span.id);
}
}
}
}
return spanMap as { [id: string]: { span: T; children: string[] } };
}
export function getStats(duration: number, traceDuration: number, selfDuration: number) {
return {
main: `${toFixedNoTrailingZeros(duration)}ms (${toFixedNoTrailingZeros((duration / traceDuration) * 100)}%)`,
secondary: `${toFixedNoTrailingZeros(selfDuration)}ms (${toFixedNoTrailingZeros(
(selfDuration / duration) * 100
)}%)`,
};
}
function toFixedNoTrailingZeros(n: number) {
return parseFloat(n.toFixed(2));
}
/**
* Create default frames used when returning data for node graph.
*/
export function makeFrames() {
const nodesFrame = new MutableDataFrame({
fields: [
{ name: Fields.id, type: FieldType.string },
{ name: Fields.title, type: FieldType.string },
{ name: Fields.subTitle, type: FieldType.string },
{ name: Fields.mainStat, type: FieldType.string, config: { displayName: 'Total time (% of trace)' } },
{ name: Fields.secondaryStat, type: FieldType.string, config: { displayName: 'Self time (% of total)' } },
{
name: Fields.color,
type: FieldType.number,
config: { color: { mode: 'continuous-GrYlRd' }, displayName: 'Self time / Trace duration' },
},
],
meta: {
preferredVisualisationType: 'nodeGraph',
},
});
const edgesFrame = new MutableDataFrame({
fields: [
{ name: Fields.id, type: FieldType.string },
{ name: Fields.target, type: FieldType.string },
{ name: Fields.source, type: FieldType.string },
],
meta: {
preferredVisualisationType: 'nodeGraph',
},
});
return [nodesFrame, edgesFrame];
}

@ -0,0 +1,27 @@
import { Store } from 'redux';
export let store: Store<StoreState>;
export const initialKeyedVariablesState: any = { keys: {} };
type StoreState = ReturnType<ReturnType<any>>;
export function setStore(newStore: Store<StoreState>) {
store = newStore;
}
export function getState(): StoreState {
if (!store || !store.getState) {
return { templating: { ...initialKeyedVariablesState, lastKey: 'key' } } as StoreState; // used by tests
}
return store.getState();
}
// This was `any` before
export function dispatch(action: any) {
if (!store || !store.getState) {
return;
}
return store.dispatch(action);
}

@ -0,0 +1,15 @@
import { FetchResponse } from '@grafana/runtime';
export function createFetchResponse<T>(data: T): FetchResponse<T> {
return {
data,
status: 200,
url: 'http://localhost:3000/api/ds/query',
config: { url: 'http://localhost:3000/api/ds/query' },
type: 'basic',
statusText: 'Ok',
redirected: false,
headers: {} as unknown as Headers,
ok: true,
};
}

@ -0,0 +1,8 @@
import { waitFor } from '@testing-library/react';
import { select } from 'react-select-event';
// Used to select an option or options from a Select in unit tests
export const selectOptionInTest = async (
input: HTMLElement,
optionOrOptions: string | RegExp | Array<string | RegExp>
) => await waitFor(() => select(input, optionOrOptions, { container: document.body }));

@ -0,0 +1,8 @@
// import { AdHocVariableFilter } from '../../../features/variables/types';
export interface AdHocVariableFilter {
key: string;
operator: string;
value: string;
/** @deprecated */
condition?: string;
}

@ -0,0 +1,155 @@
import { omitBy } from 'lodash';
import { deprecationWarning, urlUtil } from '@grafana/data';
import { BackendSrvRequest } from '@grafana/runtime';
export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => {
const method = options.method;
const headers = parseHeaders(options);
const isAppJson = isContentTypeApplicationJson(headers);
const body = parseBody(options, isAppJson);
const credentials = parseCredentials(options);
return {
method,
headers,
body,
credentials,
};
};
interface HeaderParser {
canParse: (options: BackendSrvRequest) => boolean;
parse: (headers: Headers) => Headers;
}
const defaultHeaderParser: HeaderParser = {
canParse: () => true,
parse: (headers) => {
const accept = headers.get('accept');
if (accept) {
return headers;
}
headers.set('accept', 'application/json, text/plain, */*');
return headers;
},
};
const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({
canParse: (options) => {
const method = options?.method ? options?.method.toLowerCase() : '';
return method === methodPredicate;
},
parse: (headers) => {
const contentType = headers.get('content-type');
if (contentType) {
return headers;
}
headers.set('content-type', 'application/json');
return headers;
},
});
const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post');
const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put');
const patchHeaderParser: HeaderParser = parseHeaderByMethodFactory('patch');
const headerParsers = [postHeaderParser, putHeaderParser, patchHeaderParser, defaultHeaderParser];
export const parseHeaders = (options: BackendSrvRequest) => {
const headers = options?.headers ? new Headers(options.headers) : new Headers();
const parsers = headerParsers.filter((parser) => parser.canParse(options));
const combinedHeaders = parsers.reduce((prev, parser) => {
return parser.parse(prev);
}, headers);
return combinedHeaders;
};
export const isContentTypeApplicationJson = (headers: Headers) => {
if (!headers) {
return false;
}
const contentType = headers.get('content-type');
if (contentType && contentType.toLowerCase() === 'application/json') {
return true;
}
return false;
};
export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => {
if (!options) {
return options;
}
if (!options.data || typeof options.data === 'string') {
return options.data;
}
if (options.data instanceof Blob) {
return options.data;
}
return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data);
};
export async function parseResponseBody<T>(
response: Response,
responseType?: 'json' | 'text' | 'arraybuffer' | 'blob'
): Promise<T> {
if (responseType) {
switch (responseType) {
case 'arraybuffer':
// This was `any` before; same for the other `any`s
return response.arrayBuffer() as any;
case 'blob':
return response.blob() as any;
case 'json':
// An empty string is not a valid JSON.
// Sometimes (unfortunately) our APIs declare their Content-Type as JSON, however they return an empty body.
if (response.headers.get('Content-Length') === '0') {
console.warn(`${response.url} returned an invalid JSON`);
return {} as unknown as T;
}
return await response.json();
case 'text':
return response.text() as any;
}
}
const textData = await response.text(); // this could be just a string, prometheus requests for instance
try {
return JSON.parse(textData); // majority of the requests this will be something that can be parsed
} catch {}
return textData as any;
}
export const parseUrlFromOptions = (options: BackendSrvRequest): string => {
const cleanParams = omitBy(options.params, (v) => v === undefined || (v && v.length === 0));
const serializedParams = urlUtil.serializeParams(cleanParams);
return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url;
};
export const parseCredentials = (options: BackendSrvRequest): RequestCredentials => {
if (!options) {
return options;
}
if (options.credentials) {
return options.credentials;
}
if (options.withCredentials) {
deprecationWarning('BackendSrvRequest', 'withCredentials', 'credentials');
return 'include';
}
return 'same-origin';
};

@ -5,7 +5,6 @@ import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data
import {
AdvancedHttpSettings,
Auth,
ConfigDescriptionLink,
ConfigSection,
ConfigSubSection,
ConnectionSettings,
@ -14,12 +13,16 @@ import {
} from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { SecureSocksProxySettings, useStyles2 } from '@grafana/ui';
import { Divider } from 'app/core/components/Divider';
import { NodeGraphSection } from 'app/core/components/NodeGraphSettings';
import { TraceToLogsSection } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { TraceToMetricsSection } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings';
import { TraceToProfilesSection } from 'app/core/components/TraceToProfiles/TraceToProfilesSettings';
import { SpanBarSection } from 'app/features/explore/TraceView/components/settings/SpanBarSettings';
import { ConfigDescriptionLink } from '../_importedDependencies/components/ConfigDescriptionLink';
import { Divider } from '../_importedDependencies/components/Divider';
import { NodeGraphSection } from '../_importedDependencies/components/NodeGraphSettings';
import { SpanBarSection } from '../_importedDependencies/components/TraceView/SpanBarSettings';
import {
TraceToLogsSection,
TraceToMetricsSection,
TraceToProfilesSection,
} from '../_importedDependencies/grafana-traces/src';
import { LokiSearchSettings } from './LokiSearchSettings';
import { QuerySettings } from './QuerySettings';

@ -5,8 +5,8 @@ import {
DataSourcePluginOptionsEditorProps,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { TempoJsonData } from '../types';

@ -3,9 +3,8 @@ import React from 'react';
import { DataSourcePluginOptionsEditorProps, GrafanaTheme2, updateDatasourcePluginJsonDataOption } from '@grafana/data';
import { InlineField, InlineSwitch, useStyles2 } from '@grafana/ui';
import { IntervalInput } from 'app/core/components/IntervalInput/IntervalInput';
import { invalidTimeShiftError } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { IntervalInput, invalidTimeShiftError } from '../_importedDependencies/grafana-traces/src';
import { TempoJsonData } from '../types';
interface Props extends DataSourcePluginOptionsEditorProps<TempoJsonData> {}

@ -5,8 +5,8 @@ import {
DataSourcePluginOptionsEditorProps,
updateDatasourcePluginJsonDataOption,
} from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Button, InlineField, InlineFieldRow, useStyles2 } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { TempoJsonData } from '../types';

@ -1,6 +1,4 @@
import { lastValueFrom, Observable, of } from 'rxjs';
import { createFetchResponse } from 'test/helpers/createFetchResponse';
import { initTemplateSrv } from 'test/helpers/initTemplateSrv';
import {
DataFrame,
@ -14,11 +12,17 @@ import {
PluginType,
CoreApp,
} from '@grafana/data';
import { BackendDataSourceResponse, FetchResponse, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import {
BackendDataSourceResponse,
FetchResponse,
setBackendSrv,
setDataSourceSrv,
TemplateSrv,
} from '@grafana/runtime';
import { BarGaugeDisplayMode, TableCellDisplayMode } from '@grafana/schema';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { TempoVariableQueryType } from './VariableQueryEditor';
import { createFetchResponse } from './_importedDependencies/test/helpers/createFetchResponse';
import { TraceqlSearchScope } from './dataquery.gen';
import {
DEFAULT_LIMIT,
@ -34,6 +38,7 @@ import {
import mockJson from './mockJsonResponse.json';
import mockServiceGraph from './mockServiceGraph.json';
import { createMetadataRequest, createTempoDatasource } from './mocks';
import { initTemplateSrv } from './test_utils';
import { TempoJsonData, TempoQuery } from './types';
let mockObservable: () => Observable<any>;
@ -88,21 +93,24 @@ describe('Tempo data source', () => {
const textWithPipe = 'interpolationTextOne|interpolationTextTwo';
beforeEach(() => {
templateSrv = initTemplateSrv('key', [
{
type: 'custom',
name: 'interpolationVar',
current: { value: [text] },
},
{
type: 'custom',
name: 'interpolationVarWithPipe',
current: { value: [textWithPipe] },
},
]);
const expectedValues = {
interpolationVar: 'scopedInterpolationText',
interpolationText: 'interpolationText',
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
scopedInterpolationText: 'scopedInterpolationText',
};
templateSrv = initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
});
it('when traceId query for dashboard->explore', async () => {
const expectedValues = {
interpolationVar: 'interpolationText',
interpolationText: 'interpolationText',
interpolationVarWithPipe: 'interpolationTextOne|interpolationTextTwo',
scopedInterpolationText: 'scopedInterpolationText',
};
templateSrv = initTemplateSrv([{ name: 'templateVariable1' }, { name: 'templateVariable2' }], expectedValues);
const ds = new TempoDatasource(defaultSettings, templateSrv);
const queries = ds.interpolateVariablesInQueries([getQuery()], {});
expect(queries[0].linkedQuery?.expr).toBe(`{instance=\"${text}\"}`);
@ -1006,7 +1014,7 @@ const backendSrvWithPrometheus = {
}
throw new Error('unexpected uid');
},
getDataSourceSettingsByUid(uid: string) {
getInstanceSettings(uid: string) {
if (uid === 'prom') {
return { name: 'Prometheus' };
} else if (uid === 'gdev-tempo') {

@ -26,22 +26,20 @@ import {
config,
DataSourceWithBackend,
getBackendSrv,
getDataSourceSrv,
getTemplateSrv,
reportInteraction,
TemplateSrv,
} from '@grafana/runtime';
import { BarGaugeDisplayMode, TableCellDisplayMode, VariableFormatID } from '@grafana/schema';
import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings';
import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings';
import { SpanBarOptions } from 'app/features/explore/TraceView/components';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { LokiOptions } from '../loki/types';
import { PrometheusDatasource } from '../prometheus/datasource';
import { PromQuery } from '../prometheus/types';
import { generateQueryFromFilters } from './SearchTraceQLEditor/utils';
import { TempoVariableQuery, TempoVariableQueryType } from './VariableQueryEditor';
import { NodeGraphOptions } from './_importedDependencies/components/NodeGraphSettings';
import { SpanBarOptions } from './_importedDependencies/components/TraceView/SpanBarSettings';
import { LokiOptions } from './_importedDependencies/datasources/loki/types';
import { PromQuery, PrometheusDatasource } from './_importedDependencies/datasources/prometheus/types';
import { TraceToLogsOptions } from './_importedDependencies/grafana-traces/src';
import { TraceqlFilter, TraceqlSearchScope } from './dataquery.gen';
import {
defaultTableFilter,
@ -128,6 +126,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
private readonly templateSrv: TemplateSrv = getTemplateSrv()
) {
super(instanceSettings);
this.tracesToLogs = instanceSettings.jsonData.tracesToLogs;
this.serviceMap = instanceSettings.jsonData.serviceMap;
this.search = instanceSettings.jsonData.search;
@ -168,7 +167,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
return this.labelValuesQuery(query.label);
}
default: {
throw Error('Invalid query type', query.type);
throw Error('Invalid query type: ' + query.type);
}
}
}
@ -267,7 +266,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
targets.search[0].linkedQuery?.expr && targets.search[0].linkedQuery?.expr !== '' ? true : false,
});
const dsSrv = getDatasourceSrv();
const dsSrv = getDataSourceSrv();
subQueries.push(
from(dsSrv.get(logsDatasourceUid)).pipe(
mergeMap((linkedDatasource: DataSourceApi) => {
@ -724,9 +723,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
if (query.queryType === 'nativeSearch') {
let result = [];
for (const key of ['serviceName', 'spanName', 'search', 'minDuration', 'maxDuration', 'limit']) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
if (query.hasOwnProperty(key) && query[key as keyof TempoQuery]) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
result.push(`${startCase(key)}: ${query[key as keyof TempoQuery]}`);
}
}
@ -795,7 +792,7 @@ export class TempoDatasource extends DataSourceWithBackend<TempoQuery, TempoJson
}
function queryPrometheus(request: DataQueryRequest<PromQuery>, datasourceUid: string) {
return from(getDatasourceSrv().get(datasourceUid)).pipe(
return from(getDataSourceSrv().get(datasourceUid)).pipe(
mergeMap((ds) => {
return (ds as PrometheusDatasource).query(request);
})
@ -991,7 +988,7 @@ function makePromLink(title: string, expr: string, datasourceUid: string, instan
instant: instant,
},
datasourceUid,
datasourceName: getDatasourceSrv().getDataSourceSettingsByUid(datasourceUid)?.name ?? '',
datasourceName: getDataSourceSrv().getInstanceSettings(datasourceUid)?.name ?? '',
},
};
}
@ -1078,7 +1075,7 @@ export function makeTempoLink(title: string, serviceName: string, spanName: stri
internal: {
query,
datasourceUid,
datasourceName: getDatasourceSrv().getDataSourceSettingsByUid(datasourceUid)?.name ?? '',
datasourceName: getDataSourceSrv().getInstanceSettings(datasourceUid)?.name ?? '',
},
};
}

@ -11,7 +11,12 @@ import {
toDataFrame,
} from '@grafana/data';
import { getNonOverlappingDuration, getStats, makeFrames, makeSpanMap } from '../../../core/utils/tracing';
import {
getStats,
getNonOverlappingDuration,
makeSpanMap,
makeFrames,
} from './_importedDependencies/grafana-traces/src';
/**
* Row in a trace dataFrame
@ -308,7 +313,7 @@ function collectMetricData(
}
// The name of the value column is in this format
// TODO figure out if it can be changed
// Improvement: figure out if it can be changed
const valueName = `Value #${metric}`;
for (let i = 0; i < frame.length; i++) {

@ -0,0 +1,76 @@
{
"name": "@grafana-plugins/tempo",
"description": "Grafana plugin for the Tempo data source.",
"private": true,
"version": "10.4.0-pre",
"dependencies": {
"@emotion/css": "11.11.2",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "1.7.4",
"@grafana/lezer-logql": "0.2.1",
"@grafana/lezer-traceql": "0.0.12",
"@grafana/monaco-logql": "^0.0.7",
"@grafana/runtime": "workspace:*",
"@grafana/schema": "workspace:*",
"@grafana/ui": "workspace:*",
"@lezer/common": "1.2.0",
"@lezer/lr": "1.3.3",
"@opentelemetry/api": "1.6.0",
"@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.17.1",
"@reduxjs/toolkit": "1.9.5",
"buffer": "6.0.3",
"events": "3.3.0",
"i18next": "^22.0.0",
"lodash": "4.17.21",
"lru-cache": "10.0.0",
"monaco-editor": "0.34.0",
"prismjs": "1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router": "6.2.1",
"react-use": "17.4.0",
"redux": "4.2.1",
"rxjs": "7.8.1",
"semver": "7.5.4",
"stream-browserify": "3.0.0",
"string_decoder": "1.2.0",
"tslib": "2.6.0",
"uuid": "9.0.0"
},
"devDependencies": {
"@babel/core": "7.23.2",
"@grafana/plugin-configs": "10.4.0-pre",
"@grafana/tsconfig": "^1.3.0-rc1",
"@swc/core": "1.3.38",
"@testing-library/jest-dom": "6.1.4",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.4",
"@types/lodash": "4.14.195",
"@types/node": "20.8.10",
"@types/prismjs": "1.26.0",
"@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"@types/semver": "7.5.0",
"@types/uuid": "9.0.2",
"copy-webpack-plugin": "11.0.0",
"eslint-webpack-plugin": "4.0.1",
"glob": "10.3.3",
"react-select-event": "5.5.1",
"replace-in-file-webpack-plugin": "1.0.6",
"ts-node": "10.9.1",
"typescript": "5.2.2",
"webpack": "5.89.0"
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"scripts": {
"build": "webpack -c ./webpack.config.ts --env production",
"build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)",
"dev": "webpack -w -c ./webpack.config.ts --env development"
},
"packageManager": "yarn@3.6.0"
}

@ -3,6 +3,7 @@
"name": "Tempo",
"id": "tempo",
"category": "tracing",
"executable": "gpx_tempo",
"metrics": true,
"alerting": false,
@ -27,6 +28,11 @@
"name": "GitHub Project",
"url": "https://github.com/grafana/tempo"
}
]
],
"version": "%VERSION%"
},
"dependencies": {
"grafanaDependency": ">=10.3.0-0"
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save