Skip to content

Commit 1e36c68

Browse files
borismattijssenadamyeats
authored andcommitted
Add headers to log_comment feature
1 parent ca6e861 commit 1e36c68

File tree

13 files changed

+185
-1
lines changed

13 files changed

+185
-1
lines changed

pkg/plugin/driver.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import (
3030
// Clickhouse defines how to connect to a Clickhouse datasource
3131
type Clickhouse struct{}
3232

33+
var headersAsLogCommentPrefixWhitelist = []string{"X-Dashboard", "X-Panel", "X-Rule"}
34+
3335
// getTLSConfig returns tlsConfig from settings
3436
// logic reused from https://github.com/grafana/grafana/blob/615c153b3a2e4d80cff263e67424af6edb992211/pkg/models/datasource_cache.go#L211
3537
func getTLSConfig(settings Settings) (*tls.Config, error) {
@@ -184,6 +186,15 @@ func (h *Clickhouse) Connect(ctx context.Context, config backend.DataSourceInsta
184186
httpHeaders[k] = v
185187
}
186188

189+
if settings.LogHeadersAsComment {
190+
logComment, err := headersToLogComment(httpHeaders)
191+
if err != nil {
192+
backend.Logger.Warn("Failed to serialize headers as JSON", "error", err)
193+
} else {
194+
customSettings["log_comment"] = logComment
195+
}
196+
}
197+
187198
opts := &clickhouse.Options{
188199
Addr: []string{fmt.Sprintf("%s:%d", settings.Host, settings.Port)},
189200
Auth: clickhouse.Auth{
@@ -481,6 +492,30 @@ func extractForwardedHeadersFromMessage(message json.RawMessage) (map[string]str
481492
return httpHeaders, nil
482493
}
483494

495+
// headersToLogComment serializes the headers to a JSON string for use as a log comment.
496+
func headersToLogComment(headers map[string]string) (string, error) {
497+
whiteListedHeaders := make(map[string]string)
498+
for k, v := range headers {
499+
if hasAnyPrefixCaseInsensitive(k, headersAsLogCommentPrefixWhitelist) {
500+
whiteListedHeaders[k] = v
501+
}
502+
}
503+
headersJSON, err := json.Marshal(whiteListedHeaders)
504+
if err != nil {
505+
return "", err
506+
}
507+
return string(headersJSON), nil
508+
}
509+
510+
func hasAnyPrefixCaseInsensitive(s string, prefixes []string) bool {
511+
for _, prefix := range prefixes {
512+
if strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix)) {
513+
return true
514+
}
515+
}
516+
return false
517+
}
518+
484519
func mergeOpenTelemetryLabels(frame *data.Frame) error {
485520
var attrFields []*data.Field
486521
for _, field := range frame.Fields {

pkg/plugin/driver_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,52 @@ func TestContainsClickHouseException(t *testing.T) {
317317
assert.True(t, result)
318318
})
319319
}
320+
321+
func TestHasAnyPrefixCaseInsensitive(t *testing.T) {
322+
prefixes := []string{"X-Dashboard", "X-Panel", "X-Rule"}
323+
324+
t.Run("matches case-insensitive prefix", func(t *testing.T) {
325+
assert.True(t, hasAnyPrefixCaseInsensitive("X-Dashboard-123", prefixes))
326+
assert.True(t, hasAnyPrefixCaseInsensitive("x-dashboard-123", prefixes))
327+
assert.True(t, hasAnyPrefixCaseInsensitive("X-PANEL-456", prefixes))
328+
})
329+
330+
t.Run("does not match non-whitelisted prefix", func(t *testing.T) {
331+
assert.False(t, hasAnyPrefixCaseInsensitive("X-Other-123", prefixes))
332+
assert.False(t, hasAnyPrefixCaseInsensitive("X-Grafana-Id", prefixes))
333+
})
334+
}
335+
336+
func TestHeadersToLogComment(t *testing.T) {
337+
t.Run("filters and serializes whitelisted headers", func(t *testing.T) {
338+
headers := map[string]string{
339+
"X-Dashboard-Id": "123",
340+
"X-Panel-Id": "456",
341+
"X-Grafana-Id": "should-be-excluded",
342+
"Authorization": "should-be-excluded",
343+
}
344+
345+
result, err := headersToLogComment(headers)
346+
assert.NoError(t, err)
347+
348+
var resultMap map[string]string
349+
err = json.Unmarshal([]byte(result), &resultMap)
350+
assert.NoError(t, err)
351+
352+
assert.Equal(t, "123", resultMap["X-Dashboard-Id"])
353+
assert.Equal(t, "456", resultMap["X-Panel-Id"])
354+
assert.NotContains(t, resultMap, "X-Grafana-Id")
355+
assert.NotContains(t, resultMap, "Authorization")
356+
})
357+
358+
t.Run("returns empty JSON object when no whitelisted headers", func(t *testing.T) {
359+
headers := map[string]string{
360+
"X-Grafana-Id": "value",
361+
"Authorization": "token",
362+
}
363+
364+
result, err := headersToLogComment(headers)
365+
assert.NoError(t, err)
366+
assert.Equal(t, "{}", result)
367+
})
368+
}

pkg/plugin/settings.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Settings struct {
4242

4343
HttpHeaders map[string]string `json:"-"`
4444
ForwardGrafanaHeaders bool `json:"forwardGrafanaHeaders,omitempty"`
45+
LogHeadersAsComment bool `json:"logHeadersAsComment,omitempty"`
4546
CustomSettings []CustomSetting `json:"customSettings"`
4647
ProxyOptions *proxy.Options
4748

@@ -186,6 +187,16 @@ func LoadSettings(ctx context.Context, config backend.DataSourceInstanceSettings
186187
settings.ForwardGrafanaHeaders = jsonData["forwardGrafanaHeaders"].(bool)
187188
}
188189
}
190+
if jsonData["logHeadersAsComment"] != nil {
191+
if logHeadersAsComment, ok := jsonData["logHeadersAsComment"].(string); ok {
192+
settings.LogHeadersAsComment, err = strconv.ParseBool(logHeadersAsComment)
193+
if err != nil {
194+
return settings, backend.DownstreamError(fmt.Errorf("could not parse logHeadersAsComment value: %w", err))
195+
}
196+
} else {
197+
settings.LogHeadersAsComment = jsonData["logHeadersAsComment"].(bool)
198+
}
199+
}
189200

190201
if jsonData["enableRowLimit"] != nil {
191202
if enableRowLimitString, ok := jsonData["enableRowLimit"].(string); ok {

src/components/configEditor/HttpHeadersConfig.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ describe('HttpHeadersConfig', () => {
1515
secureFields={{}}
1616
onHttpHeadersChange={() => {}}
1717
onForwardGrafanaHeadersChange={() => {}}
18+
onLogHeadersAsCommentChange={() => {}}
1819
/>
1920
);
2021
expect(result.container.firstChild).not.toBeNull();
@@ -29,6 +30,7 @@ describe('HttpHeadersConfig', () => {
2930
secureFields={{}}
3031
onHttpHeadersChange={onHttpHeadersChange}
3132
onForwardGrafanaHeadersChange={onForwardGrafanaHeadersChange}
33+
onLogHeadersAsCommentChange={() => {}}
3234
/>
3335
);
3436
expect(result.container.firstChild).not.toBeNull();
@@ -49,6 +51,7 @@ describe('HttpHeadersConfig', () => {
4951
secureFields={{}}
5052
onHttpHeadersChange={onHttpHeadersChange}
5153
onForwardGrafanaHeadersChange={onForwardGrafanaHeadersChange}
54+
onLogHeadersAsCommentChange={() => {}}
5255
/>
5356
);
5457
expect(result.container.firstChild).not.toBeNull();
@@ -98,6 +101,7 @@ describe('HttpHeadersConfig', () => {
98101
secureFields={{}}
99102
onHttpHeadersChange={onHttpHeadersChange}
100103
onForwardGrafanaHeadersChange={onForwardGrafanaHeadersChange}
104+
onLogHeadersAsCommentChange={() => {}}
101105
/>
102106
);
103107
expect(result.container.firstChild).not.toBeNull();
@@ -144,6 +148,7 @@ describe('forwardGrafanaHTTPHeaders', () => {
144148
secureFields={{}}
145149
onHttpHeadersChange={onHttpHeadersChange}
146150
onForwardGrafanaHeadersChange={onForwardGrafanaHeadersChange}
151+
onLogHeadersAsCommentChange={() => {}}
147152
/>
148153
);
149154
expect(result.container.firstChild).not.toBeNull();
@@ -154,3 +159,30 @@ describe('forwardGrafanaHTTPHeaders', () => {
154159
expect(onForwardGrafanaHeadersChange).toHaveBeenCalledTimes(1);
155160
});
156161
});
162+
163+
describe('logHeadersAsComment', () => {
164+
const selectors = allSelectors.components.Config.HttpHeaderConfig;
165+
166+
it('should call onLogHeadersAsCommentChange when switch is clicked', () => {
167+
const onHttpHeadersChange = jest.fn();
168+
const onForwardGrafanaHeadersChange = jest.fn();
169+
const onLogHeadersAsCommentChange = jest.fn();
170+
const result = render(
171+
<HttpHeadersConfig
172+
headers={[]}
173+
secureFields={{}}
174+
onHttpHeadersChange={onHttpHeadersChange}
175+
onForwardGrafanaHeadersChange={onForwardGrafanaHeadersChange}
176+
onLogHeadersAsCommentChange={onLogHeadersAsCommentChange}
177+
/>
178+
);
179+
expect(result.container.firstChild).not.toBeNull();
180+
181+
const logHeadersAsCommentSwitch = result.getByTestId(selectors.logHeadersAsCommentSwitch);
182+
expect(logHeadersAsCommentSwitch).toBeInTheDocument();
183+
fireEvent.click(logHeadersAsCommentSwitch);
184+
expect(onLogHeadersAsCommentChange).toHaveBeenCalledTimes(1);
185+
expect(onLogHeadersAsCommentChange).toHaveBeenCalledWith(true);
186+
});
187+
188+
});

src/components/configEditor/HttpHeadersConfig.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@ import { KeyValue } from '@grafana/data';
1010
interface HttpHeadersConfigProps {
1111
headers?: CHHttpHeader[];
1212
forwardGrafanaHeaders?: boolean;
13+
logHeadersAsComment?: boolean;
1314
secureFields: KeyValue<boolean>;
1415
onHttpHeadersChange: (v: CHHttpHeader[]) => void;
1516
onForwardGrafanaHeadersChange: (v: boolean) => void;
17+
onLogHeadersAsCommentChange: (v: boolean) => void;
1618
}
1719

1820
export const HttpHeadersConfig = (props: HttpHeadersConfigProps) => {
1921
const { secureFields, onHttpHeadersChange } = props;
2022
const configuredSecureHeaders = useConfiguredSecureHttpHeaders(secureFields);
2123
const [headers, setHeaders] = useState<CHHttpHeader[]>(props.headers || []);
2224
const [forwardGrafanaHeaders, setForwardGrafanaHeaders] = useState<boolean>(props.forwardGrafanaHeaders || false);
25+
const [logHeadersAsComment, setLogHeadersAsComment] = useState<boolean>(props.logHeadersAsComment || false);
2326
const labels = allLabels.components.Config.HttpHeadersConfig;
2427
const selectors = allSelectors.components.Config.HttpHeaderConfig;
2528

@@ -41,6 +44,10 @@ export const HttpHeadersConfig = (props: HttpHeadersConfigProps) => {
4144
setForwardGrafanaHeaders(value);
4245
props.onForwardGrafanaHeadersChange(value);
4346
};
47+
const updateLogHeadersAsComment = (value: boolean) => {
48+
setLogHeadersAsComment(value);
49+
props.onLogHeadersAsCommentChange(value);
50+
};
4451

4552
return (
4653
<ConfigSection title={labels.title}>
@@ -77,6 +84,14 @@ export const HttpHeadersConfig = (props: HttpHeadersConfigProps) => {
7784
onChange={(e) => updateForwardGrafanaHeaders(e.currentTarget.checked)}
7885
/>
7986
</Field>
87+
<Field label={labels.logHeadersAsComment.label} description={labels.logHeadersAsComment.tooltip}>
88+
<Switch
89+
data-testid={selectors.logHeadersAsCommentSwitch}
90+
className={'gf-form'}
91+
value={logHeadersAsComment}
92+
onChange={(e) => updateLogHeadersAsComment(e.currentTarget.checked)}
93+
/>
94+
</Field>
8095
</ConfigSection>
8196
);
8297
};

src/labels.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export default {
9191
label: 'Forward Grafana HTTP Headers',
9292
tooltip: 'Forward Grafana HTTP Headers to datasource.',
9393
},
94+
logHeadersAsComment: {
95+
label: 'Log Headers as Comment',
96+
tooltip: 'Serialize Grafana HTTP headers as JSON and include them as a log comment with each query.',
97+
},
9498
},
9599
AliasTableConfig: {
96100
title: 'Column Alias Tables',

src/selectors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export const Components = {
122122
headerNameInput: 'config__http-header-config__header-name-input',
123123
headerValueInput: 'config__http-header-config__header-value-input',
124124
forwardGrafanaHeadersSwitch: 'config__http-header-config__forward-grafana-headers-switch',
125+
logHeadersAsCommentSwitch: 'config__http-header-config__log-headers-as-comment-switch',
125126
},
126127
AliasTableConfig: {
127128
aliasEditor: 'config__alias-table-config__alias-editor',

src/types/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export interface CHConfig extends DataSourceJsonData {
3737

3838
httpHeaders?: CHHttpHeader[];
3939
forwardGrafanaHeaders?: boolean;
40+
logHeadersAsComment?: boolean;
4041

4142
customSettings?: CHCustomSetting[];
4243
enableSecureSocksProxy?: boolean;

src/views/CHConfigEditor.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
7171
const onSwitchToggle = (
7272
key: keyof Pick<
7373
CHConfig,
74-
'secure' | 'validateSql' | 'enableSecureSocksProxy' | 'forwardGrafanaHeaders' | 'enableRowLimit'
74+
'secure' | 'validateSql' | 'enableSecureSocksProxy' | 'forwardGrafanaHeaders' | 'logHeadersAsComment' | 'enableRowLimit'
7575
>,
7676
value: boolean
7777
) => {
@@ -314,11 +314,15 @@ export const ConfigEditor: React.FC<ConfigEditorProps> = (props) => {
314314
<HttpHeadersConfig
315315
headers={options.jsonData.httpHeaders}
316316
forwardGrafanaHeaders={options.jsonData.forwardGrafanaHeaders}
317+
logHeadersAsComment={options.jsonData.logHeadersAsComment}
317318
secureFields={options.secureJsonFields}
318319
onHttpHeadersChange={(headers) => onHttpHeadersChange(headers, options, onOptionsChange)}
319320
onForwardGrafanaHeadersChange={(forwardGrafanaHeaders) =>
320321
onSwitchToggle('forwardGrafanaHeaders', forwardGrafanaHeaders)
321322
}
323+
onLogHeadersAsCommentChange={(logHeadersAsComment) =>
324+
onSwitchToggle('logHeadersAsComment', logHeadersAsComment)
325+
}
322326
/>
323327
)}
324328

src/views/config-v2/HttpHeadersConfigV2.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ describe('HttpHeadersConfigV2', () => {
116116
);
117117
});
118118

119+
it('toggles "Log Headers as Comment" and calls onOptionsChange', () => {
120+
renderWith({ logHeadersAsComment: false });
121+
122+
const logHeadersCb = screen.getByLabelText(/log headers as comment/i) as HTMLInputElement;
123+
fireEvent.click(logHeadersCb);
124+
125+
expect(onOptionsChangeMock).toHaveBeenCalled();
126+
expect(onOptionsChangeMock).toHaveBeenLastCalledWith(
127+
expect.objectContaining({
128+
jsonData: expect.objectContaining({ logHeadersAsComment: true }),
129+
})
130+
);
131+
});
132+
119133
describe('HttpHeadersConfigV2', () => {
120134
const onHttpHeadersChange = jest.fn();
121135
const onOptionsChangeMock = jest.fn();

0 commit comments

Comments
 (0)