Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
44e2604
fix: e2e test on ARM/s390x are correctly reported during PRs (#6856)
JorTurFer Jun 23, 2025
f851da8
Update hpaName in ScaledObject status when HPA already exists (#6860)
justinmir Jun 28, 2025
4ebe3cc
Add Honeycomb to builder
kmoonwright Jul 3, 2025
81a8005
Create Honeycomb integration and test file
kmoonwright Jul 3, 2025
86c53b0
Update scaler query capabilities, and testing suite
kmoonwright Jul 8, 2025
a86e243
Update test cases with response parser
kmoonwright Jul 9, 2025
6ee4929
Update test cases with response parser
kmoonwright Jul 9, 2025
928ceea
Formatting...
kmoonwright Jul 9, 2025
1c79b2d
chore: changelog and issue template v2.17.2 (#6845)
wozniakjan Jul 7, 2025
92cc9fe
fix: resolve race condition in NSQ scaler tests by fixing atomic coun…
rickbrouwer Jul 7, 2025
263f2a2
Refactor Azure Log Analytics Scaler (#6701)
SpiritZhou Jul 7, 2025
a9811f8
fix: add missing omitempty tags in the AuthPodIdentity struct (#6780)
alxndr13 Jul 8, 2025
d13f467
fix: e2e test checks label value by name and not by index (#6858)
JorTurFer Jul 8, 2025
3fa2ca1
Add changelog entry for new Honeycomb Scaler
kmoonwright Jul 9, 2025
8243f56
Change limit parameter to a constant
kmoonwright Jul 9, 2025
79d26d4
Add error handling for json marchale and run query request
kmoonwright Jul 9, 2025
938274a
Add another error handling for create query
kmoonwright Jul 9, 2025
123fccd
Add better response closing in polling for query results
kmoonwright Jul 9, 2025
2d6edac
Formatting to go standards
kmoonwright Jul 9, 2025
190c396
Merge branch 'main' into feat/honeycomb-scaler
kmoonwright Jul 9, 2025
6f41074
Update changelog to note new scaler in proper section
kmoonwright Jul 9, 2025
8acf30e
Fix test imports for linter
kmoonwright Jul 9, 2025
4e42692
Merge branch 'main' into feat/honeycomb-scaler
kmoonwright Jul 10, 2025
b5c8bc8
Add error case for invalid key permissions for second Honeycomb call
kmoonwright Jul 10, 2025
a8945b1
Merge branch 'main' into feat/honeycomb-scaler
kmoonwright Jul 16, 2025
64a1e66
Add initial e2e test file
kmoonwright Jul 25, 2025
100678f
Initial e2e test implementation
kmoonwright Jul 25, 2025
30b43dc
Add comment on Query Result API limitations and reference link
kmoonwright Jul 25, 2025
c5bbf81
Add comment on query result timeout, which is limited to a response w…
kmoonwright Jul 25, 2025
016d171
Add comment on polling configurations to work within query results ra…
kmoonwright Jul 25, 2025
9032827
Format it
kmoonwright Jul 25, 2025
7d88eca
Merge upstream/main into feat/honeycomb-scaler
kmoonwright Jul 25, 2025
01dcc86
Formatting for CI checks
kmoonwright Jul 25, 2025
caaacd0
Format E2E test
kmoonwright Jul 25, 2025
c8058bf
More... formatting
kmoonwright Jul 28, 2025
4275ae7
Reorder scaler builders alphabetically
kmoonwright Jul 28, 2025
2aaf0b0
refactor: rename temporal scaler files for consistency (#6911)
aniruddhc1 Jul 27, 2025
5de0b3a
Fix scaler alphabetical ordering in buildScaler switch statement
kmoonwright Jul 29, 2025
f8d02ed
Match exact main repo scaler ordering to pass CI checks
kmoonwright Jul 29, 2025
dc6d97f
Merge branch 'main' into feat/honeycomb-scaler
kmoonwright Jul 29, 2025
cefafe4
Merge branch 'main' into feat/honeycomb-scaler
kmoonwright Aug 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ To learn more about active deprecations, we recommend checking [GitHub Discussio

## Unreleased

- **General**: Introduce new Honeycomb Scaler ([#6896](https://github.com/kedacore/keda/pull/6896/))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to New section


### New

Expand Down Expand Up @@ -152,7 +153,7 @@ New deprecation(s):
- **IBMMQ Scaler**: Handling StatusNotFound in IBMMQ scaler ([#6472](https://github.com/kedacore/keda/pull/6472))
- **MongoDB Scaler**: Support float queryValue for MongoDB scaler ([#6574](https://github.com/kedacore/keda/issues/6574))
- **Prometheus Scaler**: Add custom HTTP client timeout ([#6607](https://github.com/kedacore/keda/pull/6607))
- **RabbitMQ Scaler**: Support use of the vhostName parameter in the TriggerAuthentication resource ([#6369](https://github.com/kedacore/keda/issues/6369))
- **RabbitMQ Scaler**: Support use of the 'vhostName' parameter in the 'TriggerAuthentication' resource ([#6369](https://github.com/kedacore/keda/issues/6369))
- **Selenium Grid**: Add trigger param for Node enables managed downloads capability ([#6570](https://github.com/kedacore/keda/pull/6570))
- **Selenium Grid**: Add trigger param to set custom capabilities for matching specific Nodes ([#6536](https://github.com/kedacore/keda/issues/6536))
- **Selenium Grid**: Selenium Grid: Trigger param enableManagedDownloads set as true by default ([#6684](https://github.com/kedacore/keda/pull/6684))
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/kedacore/keda/v2

go 1.23.8
go 1.23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this required?


require (
cloud.google.com/go/compute/metadata v0.6.0
Expand Down
300 changes: 300 additions & 0 deletions pkg/scalers/honeycomb_scaler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
package scalers

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"

"github.com/go-logr/logr"
v2 "k8s.io/api/autoscaling/v2"
"k8s.io/metrics/pkg/apis/external_metrics"

"github.com/kedacore/keda/v2/pkg/scalers/scalersconfig"
kedautil "github.com/kedacore/keda/v2/pkg/util"
)

const (
honeycombScalerName = "honeycomb"
honeycombBaseURL = "https://api.honeycomb.io/1"
maxPollAttempts = 5
initialPollDelay = 2 * time.Second
honeycombQueryResultsLimit = 10000
)

type honeycombScaler struct {
metricType v2.MetricTargetType
metadata honeycombMetadata
httpClient *http.Client
logger logr.Logger
}

type honeycombMetadata struct {
APIKey string `keda:"name=apiKey, order=authParams;triggerMetadata"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't support reading secrets from metadata

Suggested change
APIKey string `keda:"name=apiKey, order=authParams;triggerMetadata"`
APIKey string `keda:"name=apiKey, order=authParams"`

Dataset string `keda:"name=dataset, order=triggerMetadata"`
Query map[string]interface{} `keda:"name=query, order=triggerMetadata, optional"`
QueryRaw string `keda:"name=queryRaw, order=triggerMetadata, optional"`
ResultField string `keda:"name=resultField, order=triggerMetadata, optional"`
ActivationThreshold float64 `keda:"name=activationThreshold, order=triggerMetadata, default=0"`
Threshold float64 `keda:"name=threshold, order=triggerMetadata"`
Breakdowns []string `keda:"name=breakdowns, order=triggerMetadata, optional"`
Calculation string `keda:"name=calculation, order=triggerMetadata, default=COUNT"`
Limit int `keda:"name=limit, order=triggerMetadata, default=1"`
TimeRange int `keda:"name=timeRange, order=triggerMetadata, default=60"`
TriggerIndex int
}

func NewHoneycombScaler(config *scalersconfig.ScalerConfig) (Scaler, error) {
metricType, err := GetMetricTargetType(config)
if err != nil {
return nil, fmt.Errorf("error getting scaler metric type: %w", err)
}

logger := InitializeLogger(config, fmt.Sprintf("%s_scaler", honeycombScalerName))

meta, err := parseHoneycombMetadata(config)
if err != nil {
return nil, fmt.Errorf("error parsing honeycomb metadata: %w", err)
}

logger.Info("Initializing Honeycomb Scaler", "dataset", meta.Dataset)

return &honeycombScaler{
metricType: metricType,
metadata: meta,
// Query Results cannot take longer than 10 seconds to run, see https://api-docs.honeycomb.io/api for details
httpClient: &http.Client{Timeout: 15 * time.Second},
logger: logger,
}, nil
}

func parseHoneycombMetadata(config *scalersconfig.ScalerConfig) (honeycombMetadata, error) {
meta := honeycombMetadata{}
err := config.TypedConfig(&meta)
if err != nil {
return meta, fmt.Errorf("error parsing honeycomb metadata: %w", err)
}
meta.TriggerIndex = config.TriggerIndex

// Use queryRaw if provided, else build query from legacy fields
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How many are options to set the config? Checking the code, user can set the config:

  • using queryRaw to provide the values as json
  • using query to provide values as map[string]interface (I'm not fully sure if this will work tbh)
  • using breakdowns, calculations, etc
    Am I right? If yes, I think that they are too many option and we should just choose one to avoid confusion to users. Are other softwares supporting those ways (maybe it's a common pattern for honeycomb) or it's just to bring all the options to users?

if raw, ok := config.TriggerMetadata["queryRaw"]; ok && raw != "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just use the parsed value?

Suggested change
if raw, ok := config.TriggerMetadata["queryRaw"]; ok && raw != "" {
if meta.QueryRaw != "" {

var q map[string]interface{}
if err := json.Unmarshal([]byte(raw), &q); err != nil {
return meta, fmt.Errorf("error parsing queryRaw: %w", err)
}
meta.Query = q
} else if meta.Query == nil {
q := make(map[string]interface{})
if len(meta.Breakdowns) > 0 {
q["breakdowns"] = meta.Breakdowns
}
if meta.Calculation != "" {
q["calculations"] = []map[string]string{{"op": meta.Calculation}}
}
if meta.Limit > 0 {
q["limit"] = meta.Limit
}
if meta.TimeRange > 0 {
q["time_range"] = meta.TimeRange
}
meta.Query = q
}
if meta.Query == nil {
return meta, errors.New("no valid query provided in 'queryRaw', 'query', or legacy fields")
}
return meta, nil
}

func (s *honeycombScaler) Close(context.Context) error { return nil }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should close idle connections on closing

Suggested change
func (s *honeycombScaler) Close(context.Context) error { return nil }
func (s *honeycombScaler) Close(context.Context) error {
if s.httpClient != nil {
s.httpClient.CloseIdleConnections()
}
return nil
}


// ----- Core Query Logic -----

func (s *honeycombScaler) executeHoneycombQuery(ctx context.Context) (float64, error) {
// 1. Create Query
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we create a query on each request? I mean, we will create multiple queries per minute, is this correct from honeycomb pov or users should create the query by their own somewhere and provide the query ID as parameter?

createURL := fmt.Sprintf("%s/queries/%s", honeycombBaseURL, s.metadata.Dataset)
bodyBytes, err := json.Marshal(s.metadata.Query)
if err != nil {
return 0, fmt.Errorf("error marshaling Honeycomb create query body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", createURL, bytes.NewBuffer(bodyBytes))
if err != nil {
return 0, fmt.Errorf("error creating Honeycomb create query request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Honeycomb-Team", s.metadata.APIKey)
resp, err := s.httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("honeycomb create query error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 201 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this API returns 200 and 201? I'd expect just only one of them

body, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("honeycomb createQuery status: %s - %s", resp.Status, string(body))
}
var createRes struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&createRes); err != nil {
return 0, fmt.Errorf("decode createQuery: %w", err)
}
if createRes.ID == "" {
return 0, errors.New("createQuery: missing query id")
}

// 2. Run Query
runURL := fmt.Sprintf("%s/query_results/%s", honeycombBaseURL, s.metadata.Dataset)
runBody, err := json.Marshal(map[string]interface{}{
"query_id": createRes.ID,
"disable_series": false,
"disable_total_by_aggregate": true,
"disable_other_by_aggregate": true,
// Query results limit is 10000, see https://api-docs.honeycomb.io/api for details
"limit": honeycombQueryResultsLimit,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to expose this to users? I mean, the limit must be enforced, but maybe a user prefers to query X items instead of the limit. Maybe we can expose this to users and check that value doesn't pass the limit.
Or maybe this is not a valid use case, saying the truth, I don't have any experience with honeycomb

})
if err != nil {
return 0, fmt.Errorf("error marshaling Honeycomb run query body: %w", err)
}
runReq, err := http.NewRequestWithContext(ctx, "POST", runURL, bytes.NewBuffer(runBody))
if err != nil {
return 0, fmt.Errorf("error creating Honeycomb run query request: %w", err)
}
runReq.Header.Set("Content-Type", "application/json")
runReq.Header.Set("X-Honeycomb-Team", s.metadata.APIKey)
runResp, err := s.httpClient.Do(runReq)
if err != nil {
return 0, fmt.Errorf("honeycomb run query error: %w", err)
}
defer runResp.Body.Close()
if runResp.StatusCode == 429 {
return 0, errors.New("honeycomb: rate limited (429), back off and try again later")
}
if runResp.StatusCode == 401 {
body, _ := io.ReadAll(runResp.Body)
return 0, fmt.Errorf("honeycomb: unauthorized (401) - an Enterprise API key is required, check your API key permissions. See: https://api-docs.honeycomb.io/api/query-data for details. Response: %s", string(body))
}
if runResp.StatusCode != 200 && runResp.StatusCode != 201 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

body, _ := io.ReadAll(runResp.Body)
return 0, fmt.Errorf("honeycomb runQuery status: %s - %s", runResp.Status, string(body))
}
var runRes struct {
ID string `json:"id"`
Complete bool `json:"complete"`
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.NewDecoder(runResp.Body).Decode(&runRes); err != nil {
return 0, fmt.Errorf("decode runQuery: %w", err)
}
if runRes.ID == "" {
return 0, errors.New("runQuery: missing queryResult id")
}
if runRes.Complete && len(runRes.Data.Results) > 0 {
return extractResultField(runRes.Data.Results, s.metadata.ResultField)
}

// 3. Poll for completion (exponential backoff)
pollURL := fmt.Sprintf("%s/query_results/%s/%s", honeycombBaseURL, s.metadata.Dataset, runRes.ID)
// Polling query results are rate limited, see https://api-docs.honeycomb.io/api for details
pollDelay := initialPollDelay
for attempt := 0; attempt < maxPollAttempts; attempt++ {
time.Sleep(pollDelay)
pollDelay *= 2
statusReq, err := http.NewRequestWithContext(ctx, "GET", pollURL, nil)
if err != nil {
return 0, fmt.Errorf("error creating Honeycomb poll query request: %w", err)
}
statusReq.Header.Set("X-Honeycomb-Team", s.metadata.APIKey)
statusResp, err := s.httpClient.Do(statusReq)
if err != nil {
return 0, fmt.Errorf("honeycomb poll query error: %w", err)
}
if statusResp.StatusCode == 429 {
statusResp.Body.Close()
return 0, errors.New("honeycomb: rate limited (429) on poll, back off and try again later")
}
if statusResp.StatusCode != 200 && statusResp.StatusCode != 201 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

body, _ := io.ReadAll(statusResp.Body)
statusResp.Body.Close()
return 0, fmt.Errorf("honeycomb pollQuery status: %s - %s", statusResp.Status, string(body))
}
var pollRes struct {
Complete bool `json:"complete"`
Data struct {
Results []map[string]interface{} `json:"results"`
} `json:"data"`
}
if err := json.NewDecoder(statusResp.Body).Decode(&pollRes); err != nil {
statusResp.Body.Close()
return 0, fmt.Errorf("pollQuery decode error: %w", err)
}
statusResp.Body.Close()
if pollRes.Complete && len(pollRes.Data.Results) > 0 {
return extractResultField(pollRes.Data.Results, s.metadata.ResultField)
}
}
return 0, errors.New("honeycomb: timed out waiting for query result")
}

func extractResultField(results []map[string]interface{}, field string) (float64, error) {
if len(results) == 0 {
return 0, errors.New("no results from Honeycomb")
}
dataObj, ok := results[0]["data"].(map[string]interface{})
if !ok {
return 0, errors.New("missing 'data' field in Honeycomb result")
}
if field == "" {
for _, v := range dataObj {
switch val := v.(type) {
case float64:
return val, nil
case int:
return float64(val), nil
case int64:
return float64(val), nil
}
}
return 0, errors.New("no numeric value found in Honeycomb result data")
}
v, ok := dataObj[field]
if !ok {
return 0, fmt.Errorf("field '%s' not found in Honeycomb result data", field)
}
switch val := v.(type) {
case float64:
return val, nil
case int:
return float64(val), nil
case int64:
return float64(val), nil
}
return 0, fmt.Errorf("no numeric value found for field '%s'", field)
}

// ----- KEDA Scaler interface -----
func (s *honeycombScaler) GetMetricsAndActivity(ctx context.Context, metricName string) ([]external_metrics.ExternalMetricValue, bool, error) {
val, err := s.executeHoneycombQuery(ctx)
if err != nil {
s.logger.Error(err, "error executing Honeycomb query")
return []external_metrics.ExternalMetricValue{}, false, err
}
metric := GenerateMetricInMili(metricName, val)
return []external_metrics.ExternalMetricValue{metric}, val > s.metadata.ActivationThreshold, nil
}

func (s *honeycombScaler) GetMetricSpecForScaling(_ context.Context) []v2.MetricSpec {
metricName := kedautil.NormalizeString(honeycombScalerName)
externalMetric := &v2.ExternalMetricSource{
Metric: v2.MetricIdentifier{
Name: GenerateMetricNameWithIndex(s.metadata.TriggerIndex, metricName),
},
Target: GetMetricTargetMili(s.metricType, s.metadata.Threshold),
}
metricSpec := v2.MetricSpec{External: externalMetric, Type: externalMetricType}
return []v2.MetricSpec{metricSpec}
}
Loading
Loading