diff --git a/core/config/app_config.go b/core/config/app_config.go index 107cf7def88..29dbb1f5acf 100644 --- a/core/config/app_config.go +++ b/core/config/app_config.go @@ -64,6 +64,7 @@ type AppConfig interface { CCV() CCV Billing() Billing BridgeStatusReporter() BridgeStatusReporter + JobSpecReporter() JobSpecReporter Sharding() Sharding LOOPP() LOOPP } diff --git a/core/config/docs/core.toml b/core/config/docs/core.toml index 31a0b510fbd..a860cf706de 100644 --- a/core/config/docs/core.toml +++ b/core/config/docs/core.toml @@ -949,6 +949,17 @@ IgnoreInvalidBridges = true # Default # IgnoreJoblessBridges skips bridges that have no associated jobs. IgnoreJoblessBridges = false # Default +# JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry. +[JobSpecReporter] +# Enabled enables the Job Spec Reporter service. +Enabled = false # Default +# PollingInterval is how often to emit a heartbeat event for each tracked job. +PollingInterval = "1h" # Default +# EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types. +EnabledOCR2PluginTypes = ["median"] # Default +# EmitNonOCR2Jobs emits telemetry for non-OCR2 job types (OCR1, Flux Monitor, Keeper). +EmitNonOCR2Jobs = false # Default + [CRE] # UseLocalTimeProvider should be set true if the DON Time OCR Plugin is not running UseLocalTimeProvider = true # Default diff --git a/core/config/job_spec_reporter_config.go b/core/config/job_spec_reporter_config.go new file mode 100644 index 00000000000..aca5c9de272 --- /dev/null +++ b/core/config/job_spec_reporter_config.go @@ -0,0 +1,14 @@ +package config + +import "time" + +type JobSpecReporter interface { + Enabled() bool + PollingInterval() time.Duration + // EnabledOCR2PluginTypes is the allowlist of OCR2 plugin types to emit for + // (e.g. "median", "ocr2keeper"). An empty slice means emit for all types. + EnabledOCR2PluginTypes() []string + // EmitNonOCR2Jobs toggles emission for non-OCR2 job types (VRF, Keeper, + // Functions, CCIP, Workflow, …). Defaults to false. + EmitNonOCR2Jobs() bool +} diff --git a/core/config/toml/types.go b/core/config/toml/types.go index af9cf192255..0f10dc9b6a8 100644 --- a/core/config/toml/types.go +++ b/core/config/toml/types.go @@ -9,6 +9,7 @@ import ( "reflect" "regexp" "strings" + "time" "github.com/google/uuid" "go.uber.org/zap/zapcore" @@ -65,6 +66,7 @@ type Core struct { CRE CreConfig `toml:",omitempty"` Billing Billing `toml:",omitempty"` BridgeStatusReporter BridgeStatusReporter `toml:",omitempty"` + JobSpecReporter JobSpecReporter `toml:",omitempty"` Sharding Sharding `toml:",omitempty"` LOOPP LOOPP `toml:",omitempty"` } @@ -112,6 +114,7 @@ func (c *Core) SetFrom(f *Core) { c.CRE.setFrom(&f.CRE) c.Billing.setFrom(&f.Billing) c.BridgeStatusReporter.setFrom(&f.BridgeStatusReporter) + c.JobSpecReporter.setFrom(&f.JobSpecReporter) c.Sharding.setFrom(&f.Sharding) c.LOOPP.setFrom(&f.LOOPP) @@ -3086,6 +3089,55 @@ func (e *BridgeStatusReporter) ValidateConfig() error { return nil } +type JobSpecReporter struct { + Enabled *bool + PollingInterval *commonconfig.Duration + EnabledOCR2PluginTypes *[]string + EmitNonOCR2Jobs *bool +} + +func (e *JobSpecReporter) setFrom(f *JobSpecReporter) { + if f.Enabled != nil { + e.Enabled = f.Enabled + } + if f.PollingInterval != nil { + e.PollingInterval = f.PollingInterval + } + if f.EnabledOCR2PluginTypes != nil { + e.EnabledOCR2PluginTypes = f.EnabledOCR2PluginTypes + } + if f.EmitNonOCR2Jobs != nil { + e.EmitNonOCR2Jobs = f.EmitNonOCR2Jobs + } +} + +func (e *JobSpecReporter) ValidateConfig() error { + if e.Enabled == nil || !*e.Enabled { + return nil + } + + if e.PollingInterval == nil { + defaultInterval := commonconfig.MustNewDuration(time.Hour) + e.PollingInterval = defaultInterval + } + + if e.PollingInterval.Duration() < config.MinimumPollingInterval { + return configutils.ErrInvalid{Name: "PollingInterval", Value: e.PollingInterval.Duration(), Msg: "must be greater than or equal to: " + config.MinimumPollingInterval.String()} + } + + if e.EnabledOCR2PluginTypes == nil { + defaultTypes := []string{"median"} + e.EnabledOCR2PluginTypes = &defaultTypes + } + + if e.EmitNonOCR2Jobs == nil { + defaultEmitNonOCR2 := false + e.EmitNonOCR2Jobs = &defaultEmitNonOCR2 + } + + return nil +} + type JobDistributor struct { DisplayName *string } diff --git a/core/scripts/go.mod b/core/scripts/go.mod index 024dcdd64dc..df562344156 100644 --- a/core/scripts/go.mod +++ b/core/scripts/go.mod @@ -506,6 +506,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect diff --git a/core/scripts/go.sum b/core/scripts/go.sum index 0359a597ff4..1ed268bdc8d 100644 --- a/core/scripts/go.sum +++ b/core/scripts/go.sum @@ -1692,6 +1692,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/core/services/chainlink/application.go b/core/services/chainlink/application.go index b9f70144d12..57b190a2c63 100644 --- a/core/services/chainlink/application.go +++ b/core/services/chainlink/application.go @@ -7,6 +7,7 @@ import ( "fmt" "math/big" "net/http" + "os" "strconv" "sync" "time" @@ -71,6 +72,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/services/keystore" "github.com/smartcontractkit/chainlink/v2/core/services/llo/retirement" "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/bridgestatus" + "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec" "github.com/smartcontractkit/chainlink/v2/core/services/ocr" "github.com/smartcontractkit/chainlink/v2/core/services/ocr2" "github.com/smartcontractkit/chainlink/v2/core/services/ocrbootstrap" @@ -800,8 +802,9 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err srvcs = append(srvcs, jobSpawner, pipelineRunner) var feedsService feeds.Service + var feedsORM feeds.ORM if cfg.Feature().FeedsManager() { - feedsORM := feeds.NewORM(opts.DS, globalLogger) + feedsORM = feeds.NewORM(opts.DS, globalLogger) feedsService = feeds.NewService( feedsORM, jobORM, @@ -824,6 +827,19 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err feedsService = &feeds.NullService{} } + hostname, _ := os.Hostname() + jobSpecReporter := jobspec.NewJobSpecReporter( + cfg.JobSpecReporter(), + jobSpawner, + feedsORM, + beholder.GetEmitter(), + csaPubKeyHex, + static.Version, + hostname, + globalLogger, + ) + srvcs = append(srvcs, jobSpecReporter) + for _, s := range srvcs { if s == nil { panic("service unexpectedly nil") diff --git a/core/services/chainlink/config_general.go b/core/services/chainlink/config_general.go index ed18c6ca7a0..08289231dee 100644 --- a/core/services/chainlink/config_general.go +++ b/core/services/chainlink/config_general.go @@ -596,6 +596,10 @@ func (g *generalConfig) BridgeStatusReporter() coreconfig.BridgeStatusReporter { return &bridgeStatusReporterConfig{c: g.c.BridgeStatusReporter} } +func (g *generalConfig) JobSpecReporter() coreconfig.JobSpecReporter { + return &jobSpecReporterConfig{c: g.c.JobSpecReporter} +} + func (g *generalConfig) Sharding() coreconfig.Sharding { return &shardingConfig{s: g.c.Sharding} } diff --git a/core/services/chainlink/config_job_spec_reporter.go b/core/services/chainlink/config_job_spec_reporter.go new file mode 100644 index 00000000000..791cbaaa1f0 --- /dev/null +++ b/core/services/chainlink/config_job_spec_reporter.go @@ -0,0 +1,42 @@ +package chainlink + +import ( + "time" + + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/config/toml" +) + +var _ config.JobSpecReporter = (*jobSpecReporterConfig)(nil) + +type jobSpecReporterConfig struct { + c toml.JobSpecReporter +} + +func (e *jobSpecReporterConfig) Enabled() bool { + if e.c.Enabled == nil { + return false + } + return *e.c.Enabled +} + +func (e *jobSpecReporterConfig) PollingInterval() time.Duration { + if e.c.PollingInterval == nil { + return time.Hour + } + return e.c.PollingInterval.Duration() +} + +func (e *jobSpecReporterConfig) EnabledOCR2PluginTypes() []string { + if e.c.EnabledOCR2PluginTypes == nil { + return []string{"median"} + } + return *e.c.EnabledOCR2PluginTypes +} + +func (e *jobSpecReporterConfig) EmitNonOCR2Jobs() bool { + if e.c.EmitNonOCR2Jobs == nil { + return false + } + return *e.c.EmitNonOCR2Jobs +} diff --git a/core/services/chainlink/config_test.go b/core/services/chainlink/config_test.go index 87ef592f7c2..f8dd91a41fe 100644 --- a/core/services/chainlink/config_test.go +++ b/core/services/chainlink/config_test.go @@ -678,6 +678,13 @@ func TestConfig_Marshal(t *testing.T) { IgnoreInvalidBridges: ptr(true), IgnoreJoblessBridges: ptr(false), } + enabledOCR2PluginTypes := []string{"median"} + full.JobSpecReporter = toml.JobSpecReporter{ + Enabled: ptr(true), + PollingInterval: commoncfg.MustNewDuration(time.Hour), + EnabledOCR2PluginTypes: &enabledOCR2PluginTypes, + EmitNonOCR2Jobs: ptr(false), + } full.Sharding = toml.Sharding{ ShardingEnabled: ptr(false), ArbiterPort: ptr[uint16](9876), diff --git a/core/services/chainlink/mocks/general_config.go b/core/services/chainlink/mocks/general_config.go index d6d602c9899..60ba7b2c281 100644 --- a/core/services/chainlink/mocks/general_config.go +++ b/core/services/chainlink/mocks/general_config.go @@ -1296,6 +1296,53 @@ func (_c *GeneralConfig_JobPipeline_Call) RunAndReturn(run func() config.JobPipe return _c } +// JobSpecReporter provides a mock function with no fields +func (_m *GeneralConfig) JobSpecReporter() config.JobSpecReporter { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for JobSpecReporter") + } + + var r0 config.JobSpecReporter + if rf, ok := ret.Get(0).(func() config.JobSpecReporter); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(config.JobSpecReporter) + } + } + + return r0 +} + +// GeneralConfig_JobSpecReporter_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JobSpecReporter' +type GeneralConfig_JobSpecReporter_Call struct { + *mock.Call +} + +// JobSpecReporter is a helper method to define mock.On call +func (_e *GeneralConfig_Expecter) JobSpecReporter() *GeneralConfig_JobSpecReporter_Call { + return &GeneralConfig_JobSpecReporter_Call{Call: _e.mock.On("JobSpecReporter")} +} + +func (_c *GeneralConfig_JobSpecReporter_Call) Run(run func()) *GeneralConfig_JobSpecReporter_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *GeneralConfig_JobSpecReporter_Call) Return(_a0 config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *GeneralConfig_JobSpecReporter_Call) RunAndReturn(run func() config.JobSpecReporter) *GeneralConfig_JobSpecReporter_Call { + _c.Call.Return(run) + return _c +} + // Keeper provides a mock function with no fields func (_m *GeneralConfig) Keeper() config.Keeper { ret := _m.Called() diff --git a/core/services/chainlink/testdata/config-empty-effective.toml b/core/services/chainlink/testdata/config-empty-effective.toml index bd00ab34ccb..53f70792803 100644 --- a/core/services/chainlink/testdata/config-empty-effective.toml +++ b/core/services/chainlink/testdata/config-empty-effective.toml @@ -399,6 +399,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/services/chainlink/testdata/config-full.toml b/core/services/chainlink/testdata/config-full.toml index a784f98a591..34ca1644255 100644 --- a/core/services/chainlink/testdata/config-full.toml +++ b/core/services/chainlink/testdata/config-full.toml @@ -437,6 +437,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = true +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/services/chainlink/testdata/config-multi-chain-effective.toml b/core/services/chainlink/testdata/config-multi-chain-effective.toml index ee3c20150eb..43f8bd91156 100644 --- a/core/services/chainlink/testdata/config-multi-chain-effective.toml +++ b/core/services/chainlink/testdata/config-multi-chain-effective.toml @@ -399,6 +399,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/services/feeds/mocks/orm.go b/core/services/feeds/mocks/orm.go index 6ce30a0a78c..7b527b0727c 100644 --- a/core/services/feeds/mocks/orm.go +++ b/core/services/feeds/mocks/orm.go @@ -1034,6 +1034,65 @@ func (_c *ORM_GetJobProposal_Call) RunAndReturn(run func(context.Context, int64) return _c } +// GetJobProposalByExternalJobID provides a mock function with given fields: ctx, externalJobID +func (_m *ORM) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*feeds.JobProposal, error) { + ret := _m.Called(ctx, externalJobID) + + if len(ret) == 0 { + panic("no return value specified for GetJobProposalByExternalJobID") + } + + var r0 *feeds.JobProposal + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (*feeds.JobProposal, error)); ok { + return rf(ctx, externalJobID) + } + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) *feeds.JobProposal); ok { + r0 = rf(ctx, externalJobID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*feeds.JobProposal) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, externalJobID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ORM_GetJobProposalByExternalJobID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobProposalByExternalJobID' +type ORM_GetJobProposalByExternalJobID_Call struct { + *mock.Call +} + +// GetJobProposalByExternalJobID is a helper method to define mock.On call +// - ctx context.Context +// - externalJobID uuid.UUID +func (_e *ORM_Expecter) GetJobProposalByExternalJobID(ctx interface{}, externalJobID interface{}) *ORM_GetJobProposalByExternalJobID_Call { + return &ORM_GetJobProposalByExternalJobID_Call{Call: _e.mock.On("GetJobProposalByExternalJobID", ctx, externalJobID)} +} + +func (_c *ORM_GetJobProposalByExternalJobID_Call) Run(run func(ctx context.Context, externalJobID uuid.UUID)) *ORM_GetJobProposalByExternalJobID_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(uuid.UUID)) + }) + return _c +} + +func (_c *ORM_GetJobProposalByExternalJobID_Call) Return(_a0 *feeds.JobProposal, _a1 error) *ORM_GetJobProposalByExternalJobID_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ORM_GetJobProposalByExternalJobID_Call) RunAndReturn(run func(context.Context, uuid.UUID) (*feeds.JobProposal, error)) *ORM_GetJobProposalByExternalJobID_Call { + _c.Call.Return(run) + return _c +} + // GetJobProposalByRemoteUUID provides a mock function with given fields: ctx, _a1 func (_m *ORM) GetJobProposalByRemoteUUID(ctx context.Context, _a1 uuid.UUID) (*feeds.JobProposal, error) { ret := _m.Called(ctx, _a1) diff --git a/core/services/feeds/orm.go b/core/services/feeds/orm.go index c0edd00a3a3..a6b6015667f 100644 --- a/core/services/feeds/orm.go +++ b/core/services/feeds/orm.go @@ -40,6 +40,7 @@ type ORM interface { DeleteProposal(ctx context.Context, id int64) error GetJobProposal(ctx context.Context, id int64) (*JobProposal, error) GetJobProposalByRemoteUUID(ctx context.Context, uuid uuid.UUID) (*JobProposal, error) + GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (*JobProposal, error) ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error) UpdateJobProposalStatus(ctx context.Context, id int64, status JobProposalStatus) error // NEEDED? UpsertJobProposal(ctx context.Context, jp *JobProposal) (int64, error) @@ -432,6 +433,21 @@ AND status <> $2; return jp, errors.Wrap(err, "GetJobProposalByRemoteUUID failed") } +// GetJobProposalByExternalJobID gets a non-deleted job proposal by its external job ID. +func (o *orm) GetJobProposalByExternalJobID(ctx context.Context, externalJobID uuid.UUID) (jp *JobProposal, err error) { + stmt := ` +SELECT * +FROM job_proposals +WHERE external_job_id = $1 +AND status <> $2 +LIMIT 1; +` + + jp = new(JobProposal) + err = o.ds.GetContext(ctx, jp, stmt, externalJobID, JobProposalStatusDeleted) + return jp, errors.Wrap(err, "GetJobProposalByExternalJobID failed") +} + // ListJobProposalsByManagersIDs gets job proposals by feeds managers IDs. func (o *orm) ListJobProposalsByManagersIDs(ctx context.Context, ids []int64) ([]JobProposal, error) { stmt := ` diff --git a/core/services/job/mocks/spawner.go b/core/services/job/mocks/spawner.go index 28ff46461b1..22b6013d4be 100644 --- a/core/services/job/mocks/spawner.go +++ b/core/services/job/mocks/spawner.go @@ -348,6 +348,39 @@ func (_c *Spawner_Ready_Call) RunAndReturn(run func() error) *Spawner_Ready_Call return _c } +// RegisterListener provides a mock function with given fields: l +func (_m *Spawner) RegisterListener(l job.Listener) { + _m.Called(l) +} + +// Spawner_RegisterListener_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegisterListener' +type Spawner_RegisterListener_Call struct { + *mock.Call +} + +// RegisterListener is a helper method to define mock.On call +// - l job.Listener +func (_e *Spawner_Expecter) RegisterListener(l interface{}) *Spawner_RegisterListener_Call { + return &Spawner_RegisterListener_Call{Call: _e.mock.On("RegisterListener", l)} +} + +func (_c *Spawner_RegisterListener_Call) Run(run func(l job.Listener)) *Spawner_RegisterListener_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(job.Listener)) + }) + return _c +} + +func (_c *Spawner_RegisterListener_Call) Return() *Spawner_RegisterListener_Call { + _c.Call.Return() + return _c +} + +func (_c *Spawner_RegisterListener_Call) RunAndReturn(run func(job.Listener)) *Spawner_RegisterListener_Call { + _c.Run(run) + return _c +} + // Start provides a mock function with given fields: _a0 func (_m *Spawner) Start(_a0 context.Context) error { ret := _m.Called(_a0) diff --git a/core/services/job/spawner.go b/core/services/job/spawner.go index e883fb23b47..2b1ad5b1d5c 100644 --- a/core/services/job/spawner.go +++ b/core/services/job/spawner.go @@ -17,6 +17,13 @@ import ( ) type ( + // Listener is notified when the Spawner starts or stops a job. + // Callbacks run asynchronously and must not block. + Listener interface { + OnJobStarted(ctx context.Context, jb Job) + OnJobStopped(ctx context.Context, jb Job) + } + // Spawner manages the spinning up and down of the long-running // services that perform the work described by job specs. Each active job spec // has 1 or more of these services associated with it. @@ -35,6 +42,10 @@ type ( // NOTE: Prefer to use CreateJob, this is only publicly exposed for use in tests // to start a job that was previously manually inserted into DB StartService(ctx context.Context, spec Job) error + + // RegisterListener adds l to the set of listeners notified on job start/stop. + // Safe to call before or after Start. + RegisterListener(l Listener) } Checker interface { @@ -52,6 +63,9 @@ type ( activeJobsMu sync.RWMutex lggr logger.Logger + listeners []Listener + listenersMu sync.RWMutex + chStop services.StopChan lbDependentAwaiters []utils.DependentAwaiter } @@ -274,6 +288,7 @@ func (js *spawner) CreateJob(ctx context.Context, ds sqlutil.DataSource, jb *Job js.lggr.Errorw("Error starting job services", "type", jb.Type, "jobID", jb.ID, "err", err) } else { js.lggr.Infow("Started job services", "type", jb.Type, "jobID", jb.ID) + js.notifyStarted(*jb) } delegate.AfterJobCreated(*jb) @@ -340,6 +355,7 @@ func (js *spawner) DeleteJob(ctx context.Context, ds sqlutil.DataSource, jobID i if exists { // Stop the service and remove the job from memory, which will always happen even if closing the services fail. js.stopService(jobID) + js.notifyStopped(aj.spec) } lggr.Infow("Stopped and deleted job") @@ -357,6 +373,47 @@ func (js *spawner) ActiveJobs() map[int32]Job { return m } +func (js *spawner) RegisterListener(l Listener) { + js.listenersMu.Lock() + defer js.listenersMu.Unlock() + js.listeners = append(js.listeners, l) +} + +func (js *spawner) notifyStarted(jb Job) { + js.dispatchToListeners(func(ctx context.Context, l Listener) { l.OnJobStarted(ctx, jb) }) +} + +func (js *spawner) notifyStopped(jb Job) { + js.dispatchToListeners(func(ctx context.Context, l Listener) { l.OnJobStopped(ctx, jb) }) +} + +// dispatchToListeners fans out fn to every registered listener in a single +// best-effort goroutine. Panics are recovered so a faulty listener cannot +// bring the spawner down. +func (js *spawner) dispatchToListeners(fn func(context.Context, Listener)) { + js.listenersMu.RLock() + ls := make([]Listener, len(js.listeners)) + copy(ls, js.listeners) + js.listenersMu.RUnlock() + + if len(ls) == 0 { + return + } + + ctx, cancel := js.chStop.NewCtx() + go func() { + defer cancel() + defer func() { + if r := recover(); r != nil { + js.lggr.Errorw("Panic in job spawner listener", "recover", r) + } + }() + for _, l := range ls { + fn(ctx, l) + } + }() +} + func (js *spawner) activeJobIDs() []int32 { js.activeJobsMu.RLock() defer js.activeJobsMu.RUnlock() diff --git a/core/services/nodestatusreporter/jobspec/events/emit.go b/core/services/nodestatusreporter/jobspec/events/emit.go new file mode 100644 index 00000000000..d7500160773 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/emit.go @@ -0,0 +1,34 @@ +package events + +import ( + "context" + "fmt" + "time" + + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" +) + +func EmitJobSpecEvent(ctx context.Context, emitter beholder.Emitter, event *JobSpecEvent) error { + if event.Timestamp == "" { + event.Timestamp = time.Now().Format(time.RFC3339Nano) + } + + eventBytes, err := proto.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal JobSpecEvent: %w", err) + } + + err = emitter.Emit(ctx, eventBytes, + "source", EventSource, + "beholder_data_schema", SchemaJobSpec, + "beholder_domain", BeholderDomain, + "beholder_entity", fmt.Sprintf("%s.%s", ProtoPkg, JobSpecEventEntity), + ) + if err != nil { + return fmt.Errorf("failed to emit JobSpecEvent: %w", err) + } + + return nil +} diff --git a/core/services/nodestatusreporter/jobspec/events/emit_test.go b/core/services/nodestatusreporter/jobspec/events/emit_test.go new file mode 100644 index 00000000000..5902a6c14e5 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/emit_test.go @@ -0,0 +1,67 @@ +package events_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest" + + "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events" +) + +func TestEmitJobSpecEvent_RoundTrip(t *testing.T) { + // NewObserver installs the global beholder client; GetEmitter returns the one to emit through. + observer := beholdertest.NewObserver(t) + emitter := beholder.GetEmitter() + + event := &events.JobSpecEvent{ + ExternalJobId: "test-job-id", + InternalJobId: 42, + Name: "test-job", + JobType: "offchainreporting2", + EmissionTrigger: events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, + } + + err := events.EmitJobSpecEvent(context.Background(), emitter, event) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + msg := msgs[0] + require.Equal(t, events.SchemaJobSpec, msg.Attrs["beholder_data_schema"]) + require.Equal(t, events.BeholderDomain, msg.Attrs["beholder_domain"]) + require.Equal(t, events.EventSource, msg.Attrs["source"]) + + var decoded events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msg.Body, &decoded)) + require.Equal(t, "test-job-id", decoded.ExternalJobId) + require.Equal(t, int32(42), decoded.InternalJobId) + require.Equal(t, "test-job", decoded.Name) + require.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, decoded.EmissionTrigger) + require.NotEmpty(t, decoded.Timestamp) +} + +func TestEmitJobSpecEvent_SetsTimestampIfEmpty(t *testing.T) { + observer := beholdertest.NewObserver(t) + emitter := beholder.GetEmitter() + + event := &events.JobSpecEvent{ + ExternalJobId: "ts-test", + } + require.Empty(t, event.Timestamp) + + err := events.EmitJobSpecEvent(context.Background(), emitter, event) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var decoded events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &decoded)) + require.NotEmpty(t, decoded.Timestamp) +} diff --git a/core/services/nodestatusreporter/jobspec/events/generate.go b/core/services/nodestatusreporter/jobspec/events/generate.go new file mode 100644 index 00000000000..28254e8a0a0 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/generate.go @@ -0,0 +1,3 @@ +package events + +// Job spec protobufs are generated in github.com/smartcontractkit/chainlink-protos/data-feeds. diff --git a/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go new file mode 100644 index 00000000000..7e11e16520e --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/job_spec.pb.go @@ -0,0 +1,27 @@ +// Code generated by chainlink-protos import shim. DO NOT EDIT. +// source: job_spec.proto + +package events + +import job_specv1 "github.com/smartcontractkit/chainlink-protos/data-feeds/job_spec/v1" + +type EmissionTrigger = job_specv1.EmissionTrigger + +const ( + EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED = job_specv1.EmissionTrigger_EMISSION_TRIGGER_UNSPECIFIED + EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT = job_specv1.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT + EmissionTrigger_EMISSION_TRIGGER_CREATE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_CREATE + EmissionTrigger_EMISSION_TRIGGER_DELETE = job_specv1.EmissionTrigger_EMISSION_TRIGGER_DELETE +) + +var ( + EmissionTrigger_name = job_specv1.EmissionTrigger_name + EmissionTrigger_value = job_specv1.EmissionTrigger_value + File_job_spec_proto = job_specv1.File_job_spec_v1_job_spec_event_proto +) + +type JobSpecEvent = job_specv1.JobSpecEvent +type OCR1OracleSpecInfo = job_specv1.OCR1OracleSpecInfo +type OCR2EVMRelayConfig = job_specv1.OCR2EVMRelayConfig +type OCR2MedianPluginConfig = job_specv1.OCR2MedianPluginConfig +type OCR2OracleSpecInfo = job_specv1.OCR2OracleSpecInfo diff --git a/core/services/nodestatusreporter/jobspec/events/job_spec.proto b/core/services/nodestatusreporter/jobspec/events/job_spec.proto new file mode 100644 index 00000000000..ad09b38d192 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/job_spec.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package job_spec.v1; + +import public "job_spec/v1/job_spec_event.proto"; +import public "job_spec/v1/ocr1_oracle_spec_info.proto"; +import public "job_spec/v1/ocr2_evm_relay_config.proto"; +import public "job_spec/v1/ocr2_median_plugin_config.proto"; +import public "job_spec/v1/ocr2_oracle_spec_info.proto"; + +option go_package = "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events"; diff --git a/core/services/nodestatusreporter/jobspec/events/types.go b/core/services/nodestatusreporter/jobspec/events/types.go new file mode 100644 index 00000000000..68f012a9b05 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/events/types.go @@ -0,0 +1,9 @@ +package events + +const ( + ProtoPkg = "job_spec.v1" + JobSpecEventEntity = "JobSpecEvent" + SchemaJobSpec = "/job-spec-events/v1" + EventSource = "chainlink-protos" + BeholderDomain = "data-feeds.job-spec" +) diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go new file mode 100644 index 00000000000..eae5c446a68 --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter.go @@ -0,0 +1,431 @@ +package jobspec + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/services" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + + coreconfig "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/feeds" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events" + medianconfig "github.com/smartcontractkit/chainlink/v2/core/services/ocr2/plugins/median/config" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" +) + +const ServiceName = "JobSpecReporter" + +// Service polls active jobs and pushes their specs to Beholder, and also emits +// on job create/delete via the job.Listener interface. +type Service struct { + services.Service + eng *services.Engine + + config coreconfig.JobSpecReporter + spawner job.Spawner + feedsORM feeds.ORM + emitter beholder.Emitter + csaPublicKey string + nodeVersion string + hostname string +} + +func NewJobSpecReporter( + config coreconfig.JobSpecReporter, + spawner job.Spawner, + feedsORM feeds.ORM, + emitter beholder.Emitter, + csaPublicKey string, + nodeVersion string, + hostname string, + lggr logger.Logger, +) *Service { + s := &Service{ + config: config, + spawner: spawner, + feedsORM: feedsORM, + emitter: emitter, + csaPublicKey: csaPublicKey, + nodeVersion: nodeVersion, + hostname: hostname, + } + s.Service, s.eng = services.Config{ + Name: ServiceName, + Start: s.start, + }.NewServiceEngine(lggr) + return s +} + +func (s *Service) start(ctx context.Context) error { + if !s.config.Enabled() { + s.eng.Info("Job Spec Reporter Service is disabled") + return nil + } + + s.eng.Info("Starting Job Spec Reporter Service") + s.spawner.RegisterListener(s) + ticker := services.NewTicker(s.config.PollingInterval()) + s.eng.GoTick(ticker, s.pollAllJobs) + + return nil +} + +func (s *Service) HealthReport() map[string]error { + return map[string]error{ServiceName: s.Ready()} +} + +// OnJobStarted emits a create event when a job starts. +func (s *Service) OnJobStarted(ctx context.Context, jb job.Job) { + if !s.ShouldEmit(&jb) { + return + } + if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE); err != nil { + s.eng.Warnw("Failed to emit job spec telemetry on create", "jobID", jb.ID, "error", err) + } +} + +// OnJobStopped emits a delete event when a job is removed. +func (s *Service) OnJobStopped(ctx context.Context, jb job.Job) { + if !s.ShouldEmit(&jb) { + return + } + if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_DELETE); err != nil { + s.eng.Warnw("Failed to emit job spec telemetry on delete", "jobID", jb.ID, "error", err) + } +} + +// pollAllJobs emits heartbeat telemetry for every active job that passes the emit gate. +func (s *Service) pollAllJobs(ctx context.Context) { + for _, jb := range s.spawner.ActiveJobs() { + if !s.ShouldEmit(&jb) { + continue + } + if err := s.EmitForJob(ctx, jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT); err != nil { + s.eng.Warnw("Failed to emit job spec telemetry", "jobID", jb.ID, "error", err) + } + } +} + +// ShouldEmit reports whether the job passes the config-driven emit gate. +// Applied to heartbeat, create, and delete events alike. +func (s *Service) ShouldEmit(j *job.Job) bool { + if j == nil { + return false + } + if j.Type != job.OffchainReporting2 || j.OCR2OracleSpec == nil { + return s.config.EmitNonOCR2Jobs() + } + allowed := s.config.EnabledOCR2PluginTypes() + if len(allowed) == 0 { + return true + } + pt := string(j.OCR2OracleSpec.PluginType) + for _, a := range allowed { + if a == pt { + return true + } + } + return false +} + +// EmitForJob builds and emits a JobSpecEvent for the given job and trigger. +func (s *Service) EmitForJob(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) error { + event, err := s.buildEvent(ctx, jb, trigger) + if err != nil { + return fmt.Errorf("building event: %w", err) + } + + if err := events.EmitJobSpecEvent(ctx, s.emitter, event); err != nil { + return fmt.Errorf("emitting event: %w", err) + } + return nil +} + +// buildEvent converts a job.Job into its protobuf JobSpecEvent representation. +func (s *Service) buildEvent(ctx context.Context, jb job.Job, trigger events.EmissionTrigger) (*events.JobSpecEvent, error) { + event := &events.JobSpecEvent{ + ExternalJobId: jb.ExternalJobID.String(), + InternalJobId: jb.ID, + Name: jb.Name.ValueOrZero(), + JobType: string(jb.Type), + SchemaVersion: jb.SchemaVersion, + ForwardingAllowed: jb.ForwardingAllowed, + MaxTaskDurationSeconds: jb.MaxTaskDuration.Duration().Seconds(), + CreatedAt: jb.CreatedAt.Format(time.RFC3339Nano), + CsaPublicKey: s.csaPublicKey, + NodeVersion: s.nodeVersion, + Hostname: s.hostname, + EmissionTrigger: trigger, + Timestamp: time.Now().Format(time.RFC3339Nano), + } + + if jb.GasLimit.Valid { + event.GasLimit = jb.GasLimit.Uint32 + } + if jb.StreamID != nil { + sid := *jb.StreamID + event.StreamId = proto.Uint32(sid) + } + + if jb.PipelineSpec != nil { + event.ObservationSource = jb.PipelineSpec.DotDagSource + event.PipelineSpecId = jb.PipelineSpec.ID + event.BridgeNames = extractBridgeNames(jb.Pipeline) + } + + if err := s.populateProposalLifecycle(ctx, jb, event); err != nil { + s.eng.Warnw("Failed to populate proposal lifecycle", "jobID", jb.ID, "error", err) + } + + if jb.Type == job.OffchainReporting2 && jb.OCR2OracleSpec != nil { + ocr2Info, err := buildOCR2OracleSpecInfo(jb.OCR2OracleSpec) + if err != nil { + return nil, fmt.Errorf("building OCR2OracleSpecInfo: %w", err) + } + event.Ocr2OracleSpec = ocr2Info + event.ContractAddress = jb.OCR2OracleSpec.ContractID + event.ChainId = jb.OCR2OracleSpec.ChainID + } + + if jb.Type == job.OffchainReporting && jb.OCROracleSpec != nil { + event.ContractAddress = jb.OCROracleSpec.ContractAddress.String() + if jb.OCROracleSpec.EVMChainID != nil { + event.ChainId = jb.OCROracleSpec.EVMChainID.String() + } + event.Ocr1OracleSpec = buildOCR1OracleSpecInfo(jb.OCROracleSpec) + } + + return event, nil +} + +// populateProposalLifecycle fills in proposal/approval fields for jobs created +// via the Feeds Manager. Jobs not managed by Feeds Manager are a no-op. +func (s *Service) populateProposalLifecycle(ctx context.Context, jb job.Job, event *events.JobSpecEvent) error { + if s.feedsORM == nil || jb.ExternalJobID == uuid.Nil { + return nil + } + + prop, err := s.feedsORM.GetJobProposalByExternalJobID(ctx, jb.ExternalJobID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("fetching job proposal: %w", err) + } + + spec, err := s.feedsORM.GetApprovedSpec(ctx, prop.ID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("fetching approved spec: %w", err) + } + + event.FeedsManagerId = prop.FeedsManagerID + event.RemoteUuid = prop.RemoteUUID.String() + event.SpecVersion = spec.Version + event.ProposedAt = spec.CreatedAt.Format(time.RFC3339Nano) + event.ApprovedAt = spec.StatusUpdatedAt.Format(time.RFC3339Nano) + event.AcceptLatencySeconds = spec.StatusUpdatedAt.Sub(spec.CreatedAt).Seconds() + return nil +} + +// extractBridgeNames returns the names of bridge tasks in the top-level pipeline. +// Tasks inside sub-pipelines (e.g. juelsPerFeeCoinSource) are not included. +func extractBridgeNames(p pipeline.Pipeline) []string { + var names []string + for _, task := range p.Tasks { + if task.Type() != pipeline.TaskTypeBridge { + continue + } + bt, ok := task.(*pipeline.BridgeTask) + if !ok { + continue + } + names = append(names, bt.Name) + } + return names +} + +// evmRelayConfig mirrors the EVM relay config JSON so we can surface its fields +// in OCR2EVMRelayConfig without depending on the EVM module. +type evmRelayConfig struct { + ChainID string `json:"chainID"` + FromBlock uint64 `json:"fromBlock"` + EffectiveTransmitterID string `json:"effectiveTransmitterID"` + EnableDualTransmission bool `json:"enableDualTransmission"` + EnableTriggerCapability bool `json:"enableTriggerCapability"` + LLODonID uint64 `json:"lloDonID"` + FeedID string `json:"feedID"` + SendingKeys []string `json:"sendingKeys"` + ProviderType string `json:"providerType"` +} + +// buildOCR2OracleSpecInfo converts an OCR2OracleSpec into the proto message. +func buildOCR2OracleSpecInfo(spec *job.OCR2OracleSpec) (*events.OCR2OracleSpecInfo, error) { + relayConfigRaw, err := json.Marshal(spec.RelayConfig) + if err != nil { + return nil, fmt.Errorf("marshaling relay config: %w", err) + } + pluginConfigRaw, err := json.Marshal(spec.PluginConfig) + if err != nil { + return nil, fmt.Errorf("marshaling plugin config: %w", err) + } + onchainStrategyRaw, err := json.Marshal(spec.OnchainSigningStrategy) + if err != nil { + return nil, fmt.Errorf("marshaling onchain signing strategy: %w", err) + } + + feedID := "" + if spec.FeedID != nil { + feedID = spec.FeedID.Hex() + } + + info := &events.OCR2OracleSpecInfo{ + SpecId: spec.ID, + FeedId: feedID, + Relay: spec.Relay, + PluginType: string(spec.PluginType), + TransmitterId: spec.TransmitterID.ValueOrZero(), + OcrKeyBundleId: spec.OCRKeyBundleID.ValueOrZero(), + MonitoringEndpoint: spec.MonitoringEndpoint.ValueOrZero(), + P2Pv2Bootstrappers: spec.P2PV2Bootstrappers, + AllowNoBootstrappers: spec.AllowNoBootstrappers, + BlockchainTimeoutSeconds: spec.BlockchainTimeout.Duration().Seconds(), + ContractConfigTrackerPollIntervalSeconds: spec.ContractConfigTrackerPollInterval.Duration().Seconds(), + ContractConfigConfirmations: uint32(spec.ContractConfigConfirmations), + CaptureEaTelemetry: spec.CaptureEATelemetry, + CaptureAutomationCustomTelemetry: spec.CaptureAutomationCustomTelemetry, + SpecCreatedAt: spec.CreatedAt.Format(time.RFC3339Nano), + SpecUpdatedAt: spec.UpdatedAt.Format(time.RFC3339Nano), + RelayConfigJson: string(relayConfigRaw), + PluginConfigJson: string(pluginConfigRaw), + OnchainSigningStrategyJson: string(onchainStrategyRaw), + } + + if spec.Relay == "evm" { + evmCfg, err := buildEVMRelayConfig(relayConfigRaw) + if err != nil { + return nil, fmt.Errorf("building EVM relay config: %w", err) + } + info.EvmRelayConfig = evmCfg + } + + if spec.PluginType == commontypes.Median { + medianCfg, err := buildMedianPluginConfig(pluginConfigRaw) + if err != nil { + return nil, fmt.Errorf("building median plugin config: %w", err) + } + info.MedianPluginConfig = medianCfg + } + + return info, nil +} + +func buildOCR1OracleSpecInfo(spec *job.OCROracleSpec) *events.OCR1OracleSpecInfo { + keyBundleID := "" + if spec.EncryptedOCRKeyBundleID != nil { + keyBundleID = spec.EncryptedOCRKeyBundleID.String() + } + + transmitterAddress := "" + if spec.TransmitterAddress != nil { + transmitterAddress = spec.TransmitterAddress.String() + } + + var dbTimeoutSeconds float64 + if spec.DatabaseTimeout != nil { + dbTimeoutSeconds = spec.DatabaseTimeout.Duration().Seconds() + } + + var gracePeriodSeconds float64 + if spec.ObservationGracePeriod != nil { + gracePeriodSeconds = spec.ObservationGracePeriod.Duration().Seconds() + } + + var transmitTimeoutSeconds float64 + if spec.ContractTransmitterTransmitTimeout != nil { + transmitTimeoutSeconds = spec.ContractTransmitterTransmitTimeout.Duration().Seconds() + } + + return &events.OCR1OracleSpecInfo{ + SpecId: spec.ID, + P2Pv2Bootstrappers: spec.P2PV2Bootstrappers, + IsBootstrapPeer: spec.IsBootstrapPeer, + OcrKeyBundleId: keyBundleID, + TransmitterAddress: transmitterAddress, + ObservationTimeoutSeconds: spec.ObservationTimeout.Duration().Seconds(), + BlockchainTimeoutSeconds: spec.BlockchainTimeout.Duration().Seconds(), + ContractConfigTrackerSubscribeIntervalSeconds: spec.ContractConfigTrackerSubscribeInterval.Duration().Seconds(), + ContractConfigTrackerPollIntervalSeconds: spec.ContractConfigTrackerPollInterval.Duration().Seconds(), + ContractConfigConfirmations: uint32(spec.ContractConfigConfirmations), + DatabaseTimeoutSeconds: dbTimeoutSeconds, + ObservationGracePeriodSeconds: gracePeriodSeconds, + ContractTransmitterTransmitTimeoutSeconds: transmitTimeoutSeconds, + CaptureEaTelemetry: spec.CaptureEATelemetry, + SpecCreatedAt: spec.CreatedAt.Format(time.RFC3339Nano), + SpecUpdatedAt: spec.UpdatedAt.Format(time.RFC3339Nano), + } +} + +// buildEVMRelayConfig decodes the EVM relay config JSON into OCR2EVMRelayConfig. +func buildEVMRelayConfig(relayConfigJSON []byte) (*events.OCR2EVMRelayConfig, error) { + var cfg evmRelayConfig + if err := json.Unmarshal(relayConfigJSON, &cfg); err != nil { + return nil, fmt.Errorf("unmarshaling EVM relay config: %w", err) + } + + return &events.OCR2EVMRelayConfig{ + ChainId: cfg.ChainID, + FromBlock: cfg.FromBlock, + EffectiveTransmitterId: cfg.EffectiveTransmitterID, + EnableDualTransmission: cfg.EnableDualTransmission, + EnableTriggerCapability: cfg.EnableTriggerCapability, + LloDonId: cfg.LLODonID, + FeedId: cfg.FeedID, + SendingKeys: cfg.SendingKeys, + ProviderType: cfg.ProviderType, + }, nil +} + +// buildMedianPluginConfig decodes the median plugin config JSON into OCR2MedianPluginConfig. +func buildMedianPluginConfig(pluginConfigJSON []byte) (*events.OCR2MedianPluginConfig, error) { + var cfg medianconfig.PluginConfig + if err := json.Unmarshal(pluginConfigJSON, &cfg); err != nil { + return nil, fmt.Errorf("unmarshaling median plugin config: %w", err) + } + + medianProto := &events.OCR2MedianPluginConfig{ + JuelsPerFeeCoinSource: cfg.JuelsPerFeeCoinPipeline, + GasPriceSubunitsSource: cfg.GasPriceSubunitsPipeline, + } + + if cfg.JuelsPerFeeCoinCache == nil { + medianProto.JuelsPerFeeCoinCacheDisabled = true + } else { + medianProto.JuelsPerFeeCoinCacheDisabled = cfg.JuelsPerFeeCoinCache.Disable + medianProto.JuelsPerFeeCoinCacheUpdateIntervalSeconds = cfg.JuelsPerFeeCoinCache.UpdateInterval.Duration().Seconds() + medianProto.JuelsPerFeeCoinCacheStalenessAlertThresholdSeconds = cfg.JuelsPerFeeCoinCache.StalenessAlertThreshold.Duration().Seconds() + } + + if cfg.DeviationFunctionDefinition != nil { + devFuncRaw, err := json.Marshal(cfg.DeviationFunctionDefinition) + if err != nil { + return nil, fmt.Errorf("marshaling deviation function definition: %w", err) + } + medianProto.DeviationFuncJson = string(devFuncRaw) + } + + return medianProto, nil +} diff --git a/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go new file mode 100644 index 00000000000..d1f9d36bcec --- /dev/null +++ b/core/services/nodestatusreporter/jobspec/job_spec_reporter_test.go @@ -0,0 +1,447 @@ +package jobspec_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "gopkg.in/guregu/null.v4" + + "github.com/smartcontractkit/chainlink-common/keystore/corekeys" + "github.com/smartcontractkit/chainlink-common/pkg/beholder" + "github.com/smartcontractkit/chainlink-common/pkg/beholder/beholdertest" + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + commontypes "github.com/smartcontractkit/chainlink-common/pkg/types" + + evmtypes "github.com/smartcontractkit/chainlink-evm/pkg/types" + + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/services/feeds" + feedsmocks "github.com/smartcontractkit/chainlink/v2/core/services/feeds/mocks" + "github.com/smartcontractkit/chainlink/v2/core/services/job" + jobmocks "github.com/smartcontractkit/chainlink/v2/core/services/job/mocks" + "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec" + "github.com/smartcontractkit/chainlink/v2/core/services/nodestatusreporter/jobspec/events" + "github.com/smartcontractkit/chainlink/v2/core/services/pipeline" +) + +// stubConfig implements config.JobSpecReporter for tests. +type stubConfig struct { + enabled bool + pollingInterval time.Duration + enabledOCR2PluginTypes []string + emitNonOCR2Jobs bool +} + +func (s *stubConfig) Enabled() bool { return s.enabled } +func (s *stubConfig) PollingInterval() time.Duration { return s.pollingInterval } +func (s *stubConfig) EnabledOCR2PluginTypes() []string { return s.enabledOCR2PluginTypes } +func (s *stubConfig) EmitNonOCR2Jobs() bool { return s.emitNonOCR2Jobs } + +func defaultConfig() *stubConfig { + return &stubConfig{ + enabled: true, + pollingInterval: time.Hour, + enabledOCR2PluginTypes: []string{"median"}, + emitNonOCR2Jobs: false, + } +} + +func makeMedianJob() job.Job { + return job.Job{ + ID: 1, + ExternalJobID: uuid.New(), + Name: null.StringFrom("test-median-job"), + Type: job.OffchainReporting2, + SchemaVersion: 1, + PipelineSpec: &pipeline.Spec{ + ID: 10, + DotDagSource: `ds1 [type=bridge name="my-bridge"]`, + }, + Pipeline: pipeline.Pipeline{ + Tasks: []pipeline.Task{ + &pipeline.BridgeTask{ + BaseTask: pipeline.NewBaseTask(0, "ds1", nil, nil, 0), + Name: "my-bridge", + }, + }, + }, + OCR2OracleSpec: &job.OCR2OracleSpec{ + ID: 1, + ContractID: "0x1234567890abcdef", + Relay: "evm", + ChainID: "1", + PluginType: commontypes.Median, + RelayConfig: job.JSONConfig{"chainID": "1"}, + PluginConfig: job.JSONConfig{"juelsPerFeeCoinSource": `ds1 [type=http method=GET url="https://example.com"]`}, + OnchainSigningStrategy: job.JSONConfig{}, + P2PV2Bootstrappers: []string{"12D3KooW@host:6688"}, + ContractConfigConfirmations: 1, + }, + CreatedAt: time.Now(), + } +} + +func makeNonMedianOCR2Job() job.Job { + jb := makeMedianJob() + jb.ID = 2 + jb.ExternalJobID = uuid.New() + jb.Name = null.StringFrom("test-keeper-job") + jb.OCR2OracleSpec = &job.OCR2OracleSpec{ + ID: 2, + ContractID: "0xabcdef1234567890", + Relay: "evm", + ChainID: "1", + PluginType: commontypes.OCR2PluginType("ocr2keeper"), + RelayConfig: job.JSONConfig{"chainID": "1"}, + PluginConfig: job.JSONConfig{}, + OnchainSigningStrategy: job.JSONConfig{}, + } + return jb +} + +func makeVRFJob() job.Job { + return job.Job{ + ID: 3, + ExternalJobID: uuid.New(), + Name: null.StringFrom("test-vrf-job"), + Type: job.VRF, + SchemaVersion: 1, + PipelineSpec: &pipeline.Spec{ID: 30, DotDagSource: ""}, + Pipeline: pipeline.Pipeline{}, + CreatedAt: time.Now(), + } +} + +// newTestReporter returns a Service wired to the current global beholder emitter. +// The caller must set up the test emitter via beholdertest.NewObserver(t) first. +func newTestReporter(t *testing.T, cfg *stubConfig, feedsORM feeds.ORM) *jobspec.Service { + t.Helper() + spawner := jobmocks.NewSpawner(t) + return jobspec.NewJobSpecReporter(cfg, spawner, feedsORM, beholder.GetEmitter(), "csa-key", "1.0.0", "test-host", logger.TestLogger(t)) +} + +// newFeedsORMWithoutProposal returns a feeds ORM mock that behaves as if the +// given job was created outside of the Feeds Manager. +func newFeedsORMWithoutProposal(t *testing.T, jb job.Job) *feedsmocks.ORM { + t.Helper() + feedsORM := feedsmocks.NewORM(t) + feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(nil, sql.ErrNoRows).Maybe() + return feedsORM +} + +func TestShouldEmit_DefaultConfig(t *testing.T) { + beholdertest.NewObserver(t) + svc := newTestReporter(t, defaultConfig(), nil) + + median := makeMedianJob() + nonMedian := makeNonMedianOCR2Job() + vrf := makeVRFJob() + + cases := []struct { + name string + jb *job.Job + want bool + }{ + {"median OCR2 job emits", &median, true}, + {"non-median OCR2 job skipped", &nonMedian, false}, + {"non-OCR2 (VRF) job skipped", &vrf, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, svc.ShouldEmit(tc.jb)) + }) + } +} + +func TestShouldEmit_AllOCR2Types(t *testing.T) { + beholdertest.NewObserver(t) + cfg := defaultConfig() + cfg.enabledOCR2PluginTypes = []string{} // empty allowlist = all OCR2 types + + svc := newTestReporter(t, cfg, nil) + + median := makeMedianJob() + nonMedian := makeNonMedianOCR2Job() + vrf := makeVRFJob() + + assert.True(t, svc.ShouldEmit(&median)) + assert.True(t, svc.ShouldEmit(&nonMedian)) + assert.False(t, svc.ShouldEmit(&vrf)) +} + +func TestShouldEmit_NonOCR2Enabled(t *testing.T) { + beholdertest.NewObserver(t) + cfg := defaultConfig() + cfg.emitNonOCR2Jobs = true + + svc := newTestReporter(t, cfg, nil) + + median := makeMedianJob() + vrf := makeVRFJob() + + assert.True(t, svc.ShouldEmit(&vrf)) + assert.True(t, svc.ShouldEmit(&median)) +} + +func TestBuildEvent_MedianJob(t *testing.T) { + observer := beholdertest.NewObserver(t) + + jb := makeMedianJob() + svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb)) + + err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + + assert.Equal(t, jb.ExternalJobID.String(), ev.ExternalJobId) + assert.Equal(t, jb.ID, ev.InternalJobId) + assert.Equal(t, "test-median-job", ev.Name) + assert.Equal(t, "offchainreporting2", ev.JobType) + assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT, ev.EmissionTrigger) + assert.Equal(t, "csa-key", ev.CsaPublicKey) + assert.Equal(t, "1.0.0", ev.NodeVersion) + assert.Equal(t, "test-host", ev.Hostname) + assert.Equal(t, []string{"my-bridge"}, ev.BridgeNames) + assert.Equal(t, "0x1234567890abcdef", ev.ContractAddress) + assert.Equal(t, "1", ev.ChainId) + require.NotNil(t, ev.Ocr2OracleSpec) + assert.Equal(t, "evm", ev.Ocr2OracleSpec.Relay) + assert.Equal(t, "median", ev.Ocr2OracleSpec.PluginType) + require.NotNil(t, ev.Ocr2OracleSpec.MedianPluginConfig) + assert.NotEmpty(t, ev.Ocr2OracleSpec.MedianPluginConfig.JuelsPerFeeCoinSource) + require.NotNil(t, ev.Ocr2OracleSpec.EvmRelayConfig) + assert.Equal(t, "1", ev.Ocr2OracleSpec.EvmRelayConfig.ChainId) + assert.Nil(t, ev.Ocr1OracleSpec) +} + +func TestBuildEvent_NonMedianOCR2Job(t *testing.T) { + observer := beholdertest.NewObserver(t) + + jb := makeNonMedianOCR2Job() + svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb)) + + err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_CREATE) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + + require.NotNil(t, ev.Ocr2OracleSpec) + assert.Equal(t, "ocr2keeper", ev.Ocr2OracleSpec.PluginType) + assert.Nil(t, ev.Ocr2OracleSpec.MedianPluginConfig) + assert.NotEmpty(t, ev.Ocr2OracleSpec.RelayConfigJson) +} + +func TestBuildEvent_NonOCR2Job(t *testing.T) { + observer := beholdertest.NewObserver(t) + + svc := newTestReporter(t, defaultConfig(), nil) + + jb := makeVRFJob() + err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + + assert.Equal(t, "vrf", ev.JobType) + assert.Nil(t, ev.Ocr2OracleSpec) +} + +func TestOnJobStarted_EmitsCreate(t *testing.T) { + observer := beholdertest.NewObserver(t) + + jb := makeMedianJob() + svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb)) + svc.OnJobStarted(context.Background(), jb) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_CREATE, ev.EmissionTrigger) +} + +func TestOnJobStopped_EmitsDelete(t *testing.T) { + observer := beholdertest.NewObserver(t) + + jb := makeMedianJob() + svc := newTestReporter(t, defaultConfig(), newFeedsORMWithoutProposal(t, jb)) + svc.OnJobStopped(context.Background(), jb) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + assert.Equal(t, events.EmissionTrigger_EMISSION_TRIGGER_DELETE, ev.EmissionTrigger) +} + +func TestOnJobStarted_SkippedWhenGateFails(t *testing.T) { + observer := beholdertest.NewObserver(t) + + // default config only allows median, so a VRF job should be skipped + svc := newTestReporter(t, defaultConfig(), nil) + svc.OnJobStarted(context.Background(), makeVRFJob()) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Empty(t, msgs) +} + +func TestBuildEvent_ProposalLifecycle(t *testing.T) { + observer := beholdertest.NewObserver(t) + feedsORM := feedsmocks.NewORM(t) + + jb := makeMedianJob() + proposedAt := time.Now().Add(-5 * time.Minute) + approvedAt := time.Now().Add(-2 * time.Minute) + + prop := &feeds.JobProposal{ + ID: 100, + FeedsManagerID: 7, + RemoteUUID: uuid.New(), + } + spec := &feeds.JobProposalSpec{ + ID: 200, + Version: 3, + CreatedAt: proposedAt, + StatusUpdatedAt: approvedAt, + } + + feedsORM.On("GetJobProposalByExternalJobID", mock.Anything, jb.ExternalJobID).Return(prop, nil) + feedsORM.On("GetApprovedSpec", mock.Anything, prop.ID).Return(spec, nil) + + svc := newTestReporter(t, defaultConfig(), feedsORM) + err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + + assert.Equal(t, int64(7), ev.FeedsManagerId) + assert.Equal(t, prop.RemoteUUID.String(), ev.RemoteUuid) + assert.Equal(t, int32(3), ev.SpecVersion) + assert.InDelta(t, approvedAt.Sub(proposedAt).Seconds(), ev.AcceptLatencySeconds, 1.0) +} + +func TestBuildEvent_ContractFields_OCR1(t *testing.T) { + observer := beholdertest.NewObserver(t) + cfg := &stubConfig{ + enabled: true, + pollingInterval: time.Hour, + emitNonOCR2Jobs: true, + } + svc := newTestReporter(t, cfg, nil) + + jb := makeOCR1Job() + err := svc.EmitForJob(context.Background(), jb, events.EmissionTrigger_EMISSION_TRIGGER_HEARTBEAT) + require.NoError(t, err) + + msgs := observer.Messages(t, "beholder_entity", events.ProtoPkg+"."+events.JobSpecEventEntity) + require.Len(t, msgs, 1) + + var ev events.JobSpecEvent + require.NoError(t, proto.Unmarshal(msgs[0].Body, &ev)) + + // top-level contract identity + assert.Equal(t, "0x9d9305445F404E925563d5D5EcC65C815Ec1655b", ev.ContractAddress) + assert.Equal(t, "11155111", ev.ChainId) + assert.Equal(t, "offchainreporting", ev.JobType) + + // OCR1 sub-message + require.NotNil(t, ev.Ocr1OracleSpec) + ocr1 := ev.Ocr1OracleSpec + assert.Equal(t, int32(99), ocr1.SpecId) + assert.Equal(t, []string{"12D3KooW@bootstrap:6688"}, ocr1.P2Pv2Bootstrappers) + assert.False(t, ocr1.IsBootstrapPeer) + assert.Equal(t, "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", ocr1.OcrKeyBundleId) + assert.Equal(t, "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", ocr1.TransmitterAddress) + assert.InDelta(t, 30.0, ocr1.ObservationTimeoutSeconds, 0.001) + assert.InDelta(t, 20.0, ocr1.BlockchainTimeoutSeconds, 0.001) + assert.InDelta(t, 120.0, ocr1.ContractConfigTrackerSubscribeIntervalSeconds, 0.001) + assert.InDelta(t, 60.0, ocr1.ContractConfigTrackerPollIntervalSeconds, 0.001) + assert.Equal(t, uint32(3), ocr1.ContractConfigConfirmations) + assert.InDelta(t, 10.0, ocr1.DatabaseTimeoutSeconds, 0.001) + assert.InDelta(t, 1.0, ocr1.ObservationGracePeriodSeconds, 0.001) + assert.InDelta(t, 5.0, ocr1.ContractTransmitterTransmitTimeoutSeconds, 0.001) + assert.True(t, ocr1.CaptureEaTelemetry) + assert.Equal(t, "2026-01-01T00:00:00Z", ocr1.SpecCreatedAt) + assert.Equal(t, "2026-02-01T00:00:00Z", ocr1.SpecUpdatedAt) + + // OCR2 sub-message absent for OCR1 jobs + assert.Nil(t, ev.Ocr2OracleSpec) +} + +func makeOCR1Job() job.Job { + keyHash, err := corekeys.Sha256HashFromHex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20") + if err != nil { + panic(err) + } + transmitter := evmtypes.MustEIP55Address("0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B") + specCreatedAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + specUpdatedAt := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + dbTimeout := sqlutil.Interval(10 * time.Second) + gracePeriod := sqlutil.Interval(1 * time.Second) + transmitTimeout := sqlutil.Interval(5 * time.Second) + return job.Job{ + ID: 4, + ExternalJobID: uuid.New(), + Name: null.StringFrom("test-ocr1-job"), + Type: job.OffchainReporting, + SchemaVersion: 1, + PipelineSpec: &pipeline.Spec{ID: 40, DotDagSource: `ds1 [type=bridge name="bridge-gsr"]`}, + Pipeline: pipeline.Pipeline{ + Tasks: []pipeline.Task{ + &pipeline.BridgeTask{ + BaseTask: pipeline.NewBaseTask(0, "ds1", nil, nil, 0), + Name: "bridge-gsr", + }, + }, + }, + OCROracleSpec: &job.OCROracleSpec{ + ID: 99, + ContractAddress: evmtypes.MustEIP55Address("0x9d9305445F404E925563d5D5EcC65C815Ec1655b"), + EVMChainID: sqlutil.NewI(11155111), + P2PV2Bootstrappers: pq.StringArray{"12D3KooW@bootstrap:6688"}, + IsBootstrapPeer: false, + EncryptedOCRKeyBundleID: &keyHash, + TransmitterAddress: &transmitter, + ObservationTimeout: sqlutil.Interval(30 * time.Second), + BlockchainTimeout: sqlutil.Interval(20 * time.Second), + ContractConfigTrackerSubscribeInterval: sqlutil.Interval(2 * time.Minute), + ContractConfigTrackerPollInterval: sqlutil.Interval(1 * time.Minute), + ContractConfigConfirmations: 3, + DatabaseTimeout: &dbTimeout, + ObservationGracePeriod: &gracePeriod, + ContractTransmitterTransmitTimeout: &transmitTimeout, + CaptureEATelemetry: true, + CreatedAt: specCreatedAt, + UpdatedAt: specUpdatedAt, + }, + CreatedAt: time.Now(), + } +} diff --git a/core/web/resolver/testdata/config-empty-effective.toml b/core/web/resolver/testdata/config-empty-effective.toml index bd00ab34ccb..53f70792803 100644 --- a/core/web/resolver/testdata/config-empty-effective.toml +++ b/core/web/resolver/testdata/config-empty-effective.toml @@ -399,6 +399,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/web/resolver/testdata/config-full.toml b/core/web/resolver/testdata/config-full.toml index 5543f86326f..68001b07d6b 100644 --- a/core/web/resolver/testdata/config-full.toml +++ b/core/web/resolver/testdata/config-full.toml @@ -416,6 +416,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/web/resolver/testdata/config-multi-chain-effective.toml b/core/web/resolver/testdata/config-multi-chain-effective.toml index 6d4542a1092..584664fd02f 100644 --- a/core/web/resolver/testdata/config-multi-chain-effective.toml +++ b/core/web/resolver/testdata/config-multi-chain-effective.toml @@ -399,6 +399,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/core/web/testdata/body/health.html b/core/web/testdata/body/health.html index cf189621cf5..1b9a4ca70d1 100644 --- a/core/web/testdata/body/health.html +++ b/core/web/testdata/body/health.html @@ -93,6 +93,9 @@
JobSpawner
+
+ JobSpecReporter +
LLOTransmissionReaper
diff --git a/core/web/testdata/body/health.json b/core/web/testdata/body/health.json index 31054ef17e7..8a2138abe59 100644 --- a/core/web/testdata/body/health.json +++ b/core/web/testdata/body/health.json @@ -171,6 +171,15 @@ "output": "" } }, + { + "type": "checks", + "id": "JobSpecReporter", + "attributes": { + "name": "JobSpecReporter", + "status": "passing", + "output": "" + } + }, { "type": "checks", "id": "LLOTransmissionReaper", diff --git a/core/web/testdata/body/health.txt b/core/web/testdata/body/health.txt index 59a77f4a057..dab28b06375 100644 --- a/core/web/testdata/body/health.txt +++ b/core/web/testdata/body/health.txt @@ -18,6 +18,7 @@ ok EVM.1399100.Txm.WrappedEvmEstimator ok HeadReporter ok Heartbeat ok JobSpawner +ok JobSpecReporter ok LLOTransmissionReaper ok Mailbox.Monitor ok Mercury.WSRPCPool diff --git a/deployment/go.mod b/deployment/go.mod index afe257dad5c..737451bc523 100644 --- a/deployment/go.mod +++ b/deployment/go.mod @@ -436,6 +436,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect diff --git a/deployment/go.sum b/deployment/go.sum index de265950030..1b058cb711c 100644 --- a/deployment/go.sum +++ b/deployment/go.sum @@ -1436,6 +1436,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d0487cd90bb..b343cfe6b62 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -2667,6 +2667,40 @@ IgnoreJoblessBridges = false # Default ``` IgnoreJoblessBridges skips bridges that have no associated jobs. +## JobSpecReporter +```toml +[JobSpecReporter] +Enabled = false # Default +PollingInterval = "1h" # Default +EnabledOCR2PluginTypes = ["median"] # Default +EmitNonOCR2Jobs = false # Default +``` +JobSpecReporter holds settings for the Job Spec Reporter service, which periodically emits job spec telemetry. + +### Enabled +```toml +Enabled = false # Default +``` +Enabled enables the Job Spec Reporter service. + +### PollingInterval +```toml +PollingInterval = "1h" # Default +``` +PollingInterval is how often to emit a heartbeat event for each tracked job. + +### EnabledOCR2PluginTypes +```toml +EnabledOCR2PluginTypes = ["median"] # Default +``` +EnabledOCR2PluginTypes restricts OCR2 telemetry to jobs with these plugin types. + +### EmitNonOCR2Jobs +```toml +EmitNonOCR2Jobs = false # Default +``` +EmitNonOCR2Jobs emits telemetry for non-OCR2 job types (OCR1, Flux Monitor, Keeper). + ## CRE ```toml [CRE] diff --git a/go.md b/go.md index 6af15e44716..b139573b8be 100644 --- a/go.md +++ b/go.md @@ -124,6 +124,8 @@ flowchart LR click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/cre/go --> chain-selectors click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos" + chainlink-protos/data-feeds + click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/job-distributor click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/linking-service/go @@ -168,6 +170,7 @@ flowchart LR chainlink/v2 --> chainlink-ccv chainlink/v2 --> chainlink-evm/contracts/cre/gobindings chainlink/v2 --> chainlink-feeds + chainlink/v2 --> chainlink-protos/data-feeds chainlink/v2 --> chainlink-protos/ring/go chainlink/v2 --> cre-sdk-go/capabilities/networking/http chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron @@ -244,6 +247,7 @@ flowchart LR chainlink-protos/chainlink-ccv/message-discovery chainlink-protos/chainlink-ccv/verifier chainlink-protos/cre/go + chainlink-protos/data-feeds chainlink-protos/job-distributor chainlink-protos/linking-service/go chainlink-protos/node-platform @@ -402,6 +406,8 @@ flowchart LR click chainlink-protos/chainlink-ccv/verifier href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/cre/go --> chain-selectors click chainlink-protos/cre/go href "https://github.com/smartcontractkit/chainlink-protos" + chainlink-protos/data-feeds + click chainlink-protos/data-feeds href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/job-distributor click chainlink-protos/job-distributor href "https://github.com/smartcontractkit/chainlink-protos" chainlink-protos/linking-service/go @@ -570,6 +576,7 @@ flowchart LR chainlink/v2 --> chainlink-ccv chainlink/v2 --> chainlink-evm/contracts/cre/gobindings chainlink/v2 --> chainlink-feeds + chainlink/v2 --> chainlink-protos/data-feeds chainlink/v2 --> chainlink-protos/ring/go chainlink/v2 --> cre-sdk-go/capabilities/networking/http chainlink/v2 --> cre-sdk-go/capabilities/scheduler/cron @@ -689,6 +696,7 @@ flowchart LR chainlink-protos/chainlink-ccv/message-discovery chainlink-protos/chainlink-ccv/verifier chainlink-protos/cre/go + chainlink-protos/data-feeds chainlink-protos/job-distributor chainlink-protos/linking-service/go chainlink-protos/node-platform diff --git a/go.mod b/go.mod index 8e6996fbccf..6e6ff7eb5da 100644 --- a/go.mod +++ b/go.mod @@ -97,6 +97,7 @@ require ( github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512-ca02ad6ed16a github.com/smartcontractkit/chainlink-protos/billing/go v0.0.0-20251024234028-0988426d98f4 github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 diff --git a/go.sum b/go.sum index cc48b7bd28e..1159f6f9b88 100644 --- a/go.sum +++ b/go.sum @@ -1282,6 +1282,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b/go.mod h1:qSTSwX3cBP3FKQwQacdjArqv0g6QnukjV4XuzO6UyoY= github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 h1:oli+2uLU6jcrJGCuYFqk3475hiwL17SWlITWLv+tx/w= diff --git a/integration-tests/go.mod b/integration-tests/go.mod index b0262691278..cad902b64b5 100644 --- a/integration-tests/go.mod +++ b/integration-tests/go.mod @@ -419,6 +419,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/op-catalog v0.0.4 // indirect diff --git a/integration-tests/go.sum b/integration-tests/go.sum index d7ea502e549..11d25557baa 100644 --- a/integration-tests/go.sum +++ b/integration-tests/go.sum @@ -1421,6 +1421,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/integration-tests/load/go.mod b/integration-tests/load/go.mod index b317f1a5688..7b7d4b0b0f1 100644 --- a/integration-tests/load/go.mod +++ b/integration-tests/load/go.mod @@ -497,6 +497,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect diff --git a/integration-tests/load/go.sum b/integration-tests/load/go.sum index 6f2865428ce..646277ace70 100644 --- a/integration-tests/load/go.sum +++ b/integration-tests/load/go.sum @@ -1689,6 +1689,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/system-tests/lib/go.mod b/system-tests/lib/go.mod index ce013d03e53..08791a08dd8 100644 --- a/system-tests/lib/go.mod +++ b/system-tests/lib/go.mod @@ -473,6 +473,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-protos/orchestrator v0.10.0 // indirect github.com/smartcontractkit/chainlink-protos/ring/go v0.0.0-20260331131315-f08a616d8dcd // indirect diff --git a/system-tests/lib/go.sum b/system-tests/lib/go.sum index ec1c7e40f4b..d7f6fcaed36 100644 --- a/system-tests/lib/go.sum +++ b/system-tests/lib/go.sum @@ -1657,6 +1657,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/system-tests/tests/go.mod b/system-tests/tests/go.mod index a4d40d8d283..9930078b29a 100644 --- a/system-tests/tests/go.mod +++ b/system-tests/tests/go.mod @@ -156,6 +156,7 @@ require ( github.com/smartcontractkit/chainlink-protos/chainlink-ccv/heartbeat v0.0.0-20260115142640-f6b99095c12e // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/message-discovery v0.0.0-20251211142334-5c3421fe2c8d // indirect github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d // indirect + github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260319180422-b5808c964785 // indirect github.com/smartcontractkit/chainlink-solana v1.1.2-0.20260421131224-c46cbfe7bc6c // indirect github.com/smartcontractkit/chainlink-solana/contracts v0.0.0-20260421131224-c46cbfe7bc6c // indirect diff --git a/system-tests/tests/go.sum b/system-tests/tests/go.sum index ca1d6d96b66..b7ee19215b0 100644 --- a/system-tests/tests/go.sum +++ b/system-tests/tests/go.sum @@ -1872,6 +1872,8 @@ github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251 github.com/smartcontractkit/chainlink-protos/chainlink-ccv/verifier v0.0.0-20251211142334-5c3421fe2c8d/go.mod h1:5JdppgngCOUS76p61zCinSCgOhPeYQ+OcDUuome5THQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877 h1:6UueUIbck1Ogarm9rm/9TS6b09mKgMmx+YE8XFg63AQ= github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260420204255-a3f3bdd56877/go.mod h1:Jqt53s27Tr0jDl8mdBXg1xhu6F8Fci8JOuq43tgHOM8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a h1:UPejHeV2qjuZxc9TLa6d0q1KbC9oW29eBWFSynVvXv8= +github.com/smartcontractkit/chainlink-protos/data-feeds v0.1.1-0.20260428231113-4e8d71d4ba0a/go.mod h1:vL1bDgPSJjV0EqHYs4dDlR+EEE0cJchgvGLYXhwIjXY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0 h1:q+VDPcxWrj5k9QizSYfUOSMnDH3Sd5HvbPguZOgfXTY= github.com/smartcontractkit/chainlink-protos/job-distributor v0.18.0/go.mod h1:/dVVLXrsp+V0AbcYGJo3XMzKg3CkELsweA/TTopCsKE= github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b h1:QuI6SmQFK/zyUlVWEf0GMkiUYBPY4lssn26nKSd/bOM= diff --git a/testdata/scripts/config/merge_raw_configs.txtar b/testdata/scripts/config/merge_raw_configs.txtar index f61352bb1f0..0036d04f4be 100644 --- a/testdata/scripts/config/merge_raw_configs.txtar +++ b/testdata/scripts/config/merge_raw_configs.txtar @@ -546,6 +546,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/health/default.txtar b/testdata/scripts/health/default.txtar index 675d2561a79..9fa76e1db1d 100644 --- a/testdata/scripts/health/default.txtar +++ b/testdata/scripts/health/default.txtar @@ -37,6 +37,7 @@ ok CRE.DispatcherWrapper ok HeadReporter ok Heartbeat ok JobSpawner +ok JobSpecReporter ok LLOTransmissionReaper ok Mailbox.Monitor ok Mercury.WSRPCPool @@ -106,6 +107,15 @@ ok WorkflowStore "output": "" } }, + { + "type": "checks", + "id": "JobSpecReporter", + "attributes": { + "name": "JobSpecReporter", + "status": "passing", + "output": "" + } + }, { "type": "checks", "id": "LLOTransmissionReaper", diff --git a/testdata/scripts/health/multi-chain-loopp.txtar b/testdata/scripts/health/multi-chain-loopp.txtar index 561bcd58a82..616d5f8e8ce 100644 --- a/testdata/scripts/health/multi-chain-loopp.txtar +++ b/testdata/scripts/health/multi-chain-loopp.txtar @@ -138,6 +138,7 @@ ok EVM.1.RelayerService.PluginRelayerClient.PluginEVM.Txm.WrappedEvmEstimator ok HeadReporter ok Heartbeat ok JobSpawner +ok JobSpecReporter ok LLOTransmissionReaper ok Mailbox.Monitor ok Mercury.WSRPCPool @@ -499,6 +500,15 @@ ok WorkflowStore "output": "" } }, + { + "type": "checks", + "id": "JobSpecReporter", + "attributes": { + "name": "JobSpecReporter", + "status": "passing", + "output": "" + } + }, { "type": "checks", "id": "LLOTransmissionReaper", diff --git a/testdata/scripts/health/multi-chain.txtar b/testdata/scripts/health/multi-chain.txtar index ad456fc2db8..a3300d75b24 100644 --- a/testdata/scripts/health/multi-chain.txtar +++ b/testdata/scripts/health/multi-chain.txtar @@ -74,6 +74,7 @@ ok EVM.1.Txm.WrappedEvmEstimator ok HeadReporter ok Heartbeat ok JobSpawner +ok JobSpecReporter ok LLOTransmissionReaper ok Mailbox.Monitor ok Mercury.WSRPCPool @@ -268,6 +269,15 @@ ok WorkflowStore "output": "" } }, + { + "type": "checks", + "id": "JobSpecReporter", + "attributes": { + "name": "JobSpecReporter", + "status": "passing", + "output": "" + } + }, { "type": "checks", "id": "LLOTransmissionReaper", diff --git a/testdata/scripts/node/validate/default.txtar b/testdata/scripts/node/validate/default.txtar index 8c68e3e8080..df7110f96a7 100644 --- a/testdata/scripts/node/validate/default.txtar +++ b/testdata/scripts/node/validate/default.txtar @@ -411,6 +411,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/defaults-override.txtar b/testdata/scripts/node/validate/defaults-override.txtar index eb5bcfa1d05..8a2dc3ef52e 100644 --- a/testdata/scripts/node/validate/defaults-override.txtar +++ b/testdata/scripts/node/validate/defaults-override.txtar @@ -472,6 +472,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar index c3931840482..d5f523a9d10 100644 --- a/testdata/scripts/node/validate/disk-based-logging-disabled.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-disabled.txtar @@ -455,6 +455,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar index 0efe1f681bf..af76ab00099 100644 --- a/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar +++ b/testdata/scripts/node/validate/disk-based-logging-no-dir.txtar @@ -455,6 +455,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/disk-based-logging.txtar b/testdata/scripts/node/validate/disk-based-logging.txtar index 5250f258f34..3c080882268 100644 --- a/testdata/scripts/node/validate/disk-based-logging.txtar +++ b/testdata/scripts/node/validate/disk-based-logging.txtar @@ -455,6 +455,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/fallback-override.txtar b/testdata/scripts/node/validate/fallback-override.txtar index d481c180565..2d90fdd5685 100644 --- a/testdata/scripts/node/validate/fallback-override.txtar +++ b/testdata/scripts/node/validate/fallback-override.txtar @@ -553,6 +553,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar index 8b92ebfd791..43c4fe73c52 100644 --- a/testdata/scripts/node/validate/invalid-ocr-p2p.txtar +++ b/testdata/scripts/node/validate/invalid-ocr-p2p.txtar @@ -440,6 +440,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/invalid.txtar b/testdata/scripts/node/validate/invalid.txtar index e245512e424..d552c5f70c8 100644 --- a/testdata/scripts/node/validate/invalid.txtar +++ b/testdata/scripts/node/validate/invalid.txtar @@ -451,6 +451,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/valid.txtar b/testdata/scripts/node/validate/valid.txtar index 8db0b37ffaf..f2941e4d1c8 100644 --- a/testdata/scripts/node/validate/valid.txtar +++ b/testdata/scripts/node/validate/valid.txtar @@ -452,6 +452,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876 diff --git a/testdata/scripts/node/validate/warnings.txtar b/testdata/scripts/node/validate/warnings.txtar index 3ab643dd95b..55d8b01019b 100644 --- a/testdata/scripts/node/validate/warnings.txtar +++ b/testdata/scripts/node/validate/warnings.txtar @@ -434,6 +434,12 @@ PollingInterval = '5m0s' IgnoreInvalidBridges = true IgnoreJoblessBridges = false +[JobSpecReporter] +Enabled = false +PollingInterval = '1h0m0s' +EnabledOCR2PluginTypes = ['median'] +EmitNonOCR2Jobs = false + [Sharding] ShardingEnabled = false ArbiterPort = 9876