-
Notifications
You must be signed in to change notification settings - Fork 1.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Connectors prototype v2 #6372
Connectors prototype v2 #6372
Conversation
af859b6
to
001d0d2
Compare
Codecov ReportBase: 90.36% // Head: 90.45% // Increases project coverage by
Additional details and impacted files@@ Coverage Diff @@
## main #6372 +/- ##
==========================================
+ Coverage 90.36% 90.45% +0.09%
==========================================
Files 244 249 +5
Lines 14161 15083 +922
==========================================
+ Hits 12796 13644 +848
- Misses 1115 1142 +27
- Partials 250 297 +47
Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here. ☔ View full report at Codecov. |
5021741
to
ca14718
Compare
// then process and export the metric to the appropriate backend. | ||
type MetricsToMetricsConnector interface { | ||
Connector | ||
ConsumeMetricsToMetrics(ctx context.Context, tm pmetric.Metrics) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need new func (ConsumeMetricsToMetrics
) and just implementing consumer.Metrics
is not enough? Signature is equivalent.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was my original implementation but I found it to be confusing in some ways. I don't think there's anything that technically prevents us from doing it as you suggested so I can switch it back if you want.
To try to summarize the confusion I found though:
Conceptually, any connector that consumes traces is a consumer.Traces
, regardless of what it does with them. We also need each signal combination to implement a different interface, and as you pointed out consumer.Traces
can differentiate traces->traces
from the other combinations. However, the others ultimately must be wrapped in consumer.Traces
as well in order to allow other components to send data to them. I found this confusing because it means that traces->traces
is always consumer.Traces
, but traces->metrics
is only sometimes consumer.Traces
, depending on where in the code. I found it easier to just treat all combinations the same so we can say they each have a dedicated interface, and so they all become consumer.Traces
at the same time when building the graph.
I'm not certain, but from a component author's point of view, the same confusion may be relevant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you give me a bit of code snippet to make sure we understand. I am a bit confused about the explanation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try to explain better. It may be necessary to first step back and highlight a couple design questions that I worked through in this implementation.
- What constraints should be placed on the input->output types supported by a connector type?
- Based on the input/output constraints, how do users reason about configuration?
- Based on the input/output constraints, how should connectors be managed within the collector - instanced per-pipeline like processors, shared per-signal like receivers/exporters, or something else?
What constraints should be placed on the input->output types supported by a connector type?
I considered the following options:
- A connector type can support exactly one input/output combination. This is too granular and prevents intuitive use cases like
nop
andcount
, instead requiringnoptraces
,nopmetrics
, etc. - A connector type can support exactly one output type per input type. This works for
nop
(traces->traces, metrics->metrics, logs->logs
) andcount
(traces->metrics, metrics->metrics, logs->logs
). - A connector type can support any or all input/output combinations. This is the least restrictive but introduces some implementation considerations. Notably though, this. is necessary to directly support behavior like
spanmetricsprocessor
, which both derives metrics from spans and allows the original spans to pass through unmodified. (i.e.traces->traces, traces->metrics
).
I think option 3 is best because it allows for the most use cases / is the least restrictive.
Based on the input/output constraints, how do users reason about configuration?
In short, a connector will do all the things it can do that are applicable to the pipelines it connects.
- A connector type may consume one or more signal types and emit one or more signal types.
- Each connector type declares support for specific combinations of signal types.
- Each signal combination supported by a connector is an independent function. That is, the connector does not require that you use all if its capabilities. For example, if the
count
connector is configured to connect from a logs pipeline to a metrics pipeline, it will count logs. Likewise, if you do not want thespanmetrics
connector to forward traces, you can configure only thetraces->metrics
connection and ignore thetraces->traces
capability. - A connector will consume/emit for each pair of pipelines that it is connected.
- If a connector configured in such a way that any combination of its input/output pipelines are unsupported, this will result in a configuration error.
Based on the input/output constraints, how should connectors be managed within the collector - instanced per-pipeline like processors, shared per-signal like receivers/exporters, or something else?
A connector is instanced based on each input/output type. This is similar to how receivers and exporters are managed internally. Specially, receivers and exporters are instanced per signal type. (Technically, the component developer can use sharedcomponent
to unify these instances, but the collector manages them via a separate handle for each signal type.)
This example shows a connector that is consuming and emitting all three signal types. Internally, the collector will instantiate 9 instances of this connector, with each instance constructed by the corresponding Create<DataType>To<DataType>Connector
factory method. Likewise, each instance of the connector will consume signals by performing the corresponding Consume<DataType>To<DataType>
function.
In such a case, each of the 9 instances must ultimately satisfy one of the consumer.<DataType>
interfaces. This is done by wrapping each instances via the appropriate consumer.New<DataType>
function. Here is the code that handles this, but as a simplified version:
switch receiverPipelineType {
case component.DataTypeTraces:
...
switch exporterPipelineType {
case component.DataTypeTraces:
connectorInstance := n.factory.CreateTracesToTracesConnector(ctx, set, cfg, fanoutConsumer)
tracesConsumer := consumer.NewTraces(connectorInstance.ConsumeTracesToTraces)
...
case component.DataTypeMetrics:
connectorInstance := n.factory.CreateMetricsToTracesConnector(ctx, set, cfg, fanoutConsumer)
metricsConsumer := consumer.NewMetrics(connectorInstance.ConsumeMetricsToTraces)
...
case component.DataTypeLogs:
connectorInstance := n.factory.CreateLogsToTracesConnector(ctx, set, cfg, fanoutConsumer)
logsConsumer := consumer.NewLogs(connectorInstance.ConsumeLogsToTraces)
...
}
We could rename ConsumeTracesToTraces
to ConsumeTraces
, but then it is a special case vs ConsumeMetricsToTraces
and ConsumeLogsToTraces
. This is harder to keep track of in my opinion. It becomes especially awkward when trying to unwrap the connector instance, such as is necessary in tests when validating that each component in the graph is as expected. By treating each signal combination as equal, it allows for uniform code such as this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@djaglowski this requires every pairwise combination of signals to have an interface. Is this approach going to scale if we add one or two other new signals? It is 3x3 now, if we add 2 more signals (e.g. profiles and entities), we will have 5x5=25 interfaces, 25 methods and 25 switch cases, etc.
Is there a way to use generics here somehow to reduce the code volume? (Probably no, I don't remember Go's genics having much compile-time power to generate combinations of type parameters).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
25 combinations seems... manageable, if far from ideal. The scalability issues is somewhat mitigated in that we likely will not exceed 5 signal types any time soon. I agree though - the current implementation will become quite cumbersome when adding additional signals.
I'll spend some time looking into this. There might be a way to decouple the input/output types so that a connector has to implement one consumer type and one "emitter" type, and then rely on composition rather than pairwise combinations.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have not been able to identify a good way to do this.
- I don't see a way to use generics here, though I may be missing it.
- I tried removing the pairwise interfaces and defining one interface per signal type, with the signal type indicating the type of consumer that the connector itself it. This lead to a need to expose a generic
consumer.Consumer
which each implementation had to type check during connector creation. This feels error prone and I don't see how to avoid it. - I looked into defining a "TracesConnector" as a connector that emits traces, but this feels like a nonstarter as it completely sidesteps the usefulness of each component being a clear type of consumer.
There may be a better way here, but I wonder if we can live with the explicit pairwise code, at least initially.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll play around with this a bit, but I believe it might be possible to use generics here to alleviate at least part of the combinatorial problem.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bogdandrutu, thanks for taking a look.
I responded to your feedback and am continuing to work through tests.
// then process and export the metric to the appropriate backend. | ||
type MetricsToMetricsConnector interface { | ||
Connector | ||
ConsumeMetricsToMetrics(ctx context.Context, tm pmetric.Metrics) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was my original implementation but I found it to be confusing in some ways. I don't think there's anything that technically prevents us from doing it as you suggested so I can switch it back if you want.
To try to summarize the confusion I found though:
Conceptually, any connector that consumes traces is a consumer.Traces
, regardless of what it does with them. We also need each signal combination to implement a different interface, and as you pointed out consumer.Traces
can differentiate traces->traces
from the other combinations. However, the others ultimately must be wrapped in consumer.Traces
as well in order to allow other components to send data to them. I found this confusing because it means that traces->traces
is always consumer.Traces
, but traces->metrics
is only sometimes consumer.Traces
, depending on where in the code. I found it easier to just treat all combinations the same so we can say they each have a dedicated interface, and so they all become consumer.Traces
at the same time when building the graph.
I'm not certain, but from a component author's point of view, the same confusion may be relevant.
e6021ef
to
244af4c
Compare
Thank you, that's kind. And I appreciate that you are willing to hear feedback on usability aspects of it, which I know can be tedious at times (but so important to get right).
I do so wish that collector configs I work with would fit on one (or even two) pages in my editor. 😂 But I do see the point you're making. I will concede that perhaps my usability worries are not representative of the what the average person may be working with!
Reading through the rest of your comment and the subsequent ones with the options considered: perhaps this is the best way after all. I appreciate that you walked us through all the options so thoroughly, it really helps me to see that this may not be ideal but is a lot better than many feasible alternatives. I'll keep thinking about configuration usability in general, and if I come up with anything maybe we can look at that as a general improvement to make for the collector overall. :) |
I've pulled out the skeleton of the new component type for review here: #6577 |
@djaglowski a slight modification of what you suggested: connectors:
count/1: # converts telemetry to metrics by counting the number of records
service:
pipelines:
logs/blue:
receivers: [filelog/blue]
exporters: [file/logs_blue]
connect_to:
- pipeline: logs/all # forward from logs/blue pipeline to logs/all pipeline the data as is.
logs/green:
receivers: [filelog/green]
exporters: [file/logs_green]
connect_to:
- pipeline: metrics/all # forward from logs/green pipeline to metrics/all pipeline
via: count/1 # use this connector logic. If omitted uses "forward" ("noop") connector.
logs/all:
exporters: [file/logs_all]
metrics/all:
... With this approach there is no need to refer to the same connector twice, we only refer to it in the source pipeline. |
Is it really a problem to refer to a connector twice? What is the goal in preventing this? To me, this approach appears to be more verbose anyways, and more importantly I think it blurs some concepts about the pipeline/component model that users already understand. At the core of our pipeline model is a very simple rule, that data enters via receivers and exits via exporters. This presentation conveys that we are sending data "from a pipeline, to a pipeline" and immediately raises questions about whether there is an exporter or receiver involved. (Of course there is, but shouldn't this be apparent to users?) Arguably this presentation also challenges the notion that, while a pipeline is an arrangement of components, it is the components themselves that handle the data. If connectors are handling data, aren't they necessarily components? Isn't it then more intuitive to clearly represent connectors as components that are inside of pipelines? I think we need to decide how we want users to understand connectors. Should they understand that a connector is a component and therefore exists inside of pipelines, as they understand with receivers, processors, and exporters? Or, do we want them to adopt a mental model where connectors are a special concept with an ambiguous relationship to the component/pipeline model? I personally have a strong preference for the former, and I think that the original design does the best job of conveying this model to the user. |
It seems slightly less verbose, particularly when using the "nop" connector. The downside is it is more "asymmetric", I somehow like the symmetry in your proposal.
I am OK with your proposal. I do not think what I suggested is much better. |
4b5bac6
to
f44b022
Compare
f44b022
to
67b2867
Compare
@@ -32,6 +32,9 @@ type Factories struct { | |||
|
|||
// Extensions maps extension type names in the config to the respective factory. | |||
Extensions map[Type]ExtensionFactory | |||
|
|||
// Connectors maps connector type names in the config to the respective factory. | |||
Connectors map[Type]ConnectorFactory |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the factory was added to connectors
package in #6611, this would ideally be changed:
Connectors map[Type]ConnectorFactory | |
Connectors map[Type]connector.Factory |
However, this causes a circular dependency.
@bogdandrutu, is the plan still to move Factories
to the service
package, similar to #6552?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes :) You can create a PR for that, would be great. Start with a prototype (just the public api changes) so I can give feedback and not feel bad that you have to re-work things :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see some work was done on this. For my part, I had taken a crack at it but hadn't worked out how to avoid circular dependency problems.
This branch is finally rebased after the removal of service/internal/pipelines
, so I will take another look at this soon.
67b2867
to
d6d8bd6
Compare
Just wanted to mention that this is high on my list to review :-) |
While I love the idea of having a native way to convert signals, I still couldn't think of a use case for the pure connection of pipelines that today's features wouldn't cover. Concretely, I don't understand the advantage of this:
over this:
|
Adding some processors can illustrate the usefulness of same-signal connectors. Below, suppose we need to pre-process the two files in different ways, and then subsequently we wish to batch all the resulting logs together. (I'll call the connector service:
pipelines:
logs/blue:
receivers: [filelog/blue]
processors: [transform/blue] # something particular to blue
exporters: [backend/blue, forward]
logs/green:
receivers: [filelog/green]
processors: [transform/green] # something particular to green
exporters: [backend/green, forward]
logs/all:
receivers: [forward]
processors: [batch, transform/common]
exporters: [backend/all] Another case based only on service:
pipelines:
logs:
receivers: [filelog]
processor: [transform]
exporters: [forward]
logs/all:
receivers: [forward]
exporters: [low_cost_backend]
logs/filtered:
receivers: [forward]
processors: [filter]
exporters: [expensive_backend] Arguably, the service:
pipelines:
logs:
receivers: [filelog]
exporters: [routing] # routes to one pipeline or the other based on some condition
logs/type1:
receivers: [routing]
processors: [transform/type1]
exporters: [backend]
logs/type2:
receivers: [forward]
processors: [transform/type2]
exporters: [backend] Another example is the service:
pipelines:
metrics/in:
receivers: [otlp]
exporters: [nop, count]
metrics/out:
receivers: [nop]
processors: [transform] # something particular to the original signals
exporters: [backend]
metric/counts:
receivers: [count]
exporters: [backend/counts_only] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't finish the review yet but I wanted to release the comments before I leave for the day.
cmd/otelcorecol/builder-config.yaml
Outdated
@@ -17,11 +17,18 @@ extensions: | |||
processors: | |||
- gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.65.0 | |||
- gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.65.0 | |||
connectors: | |||
- import: go.opentelemetry.io/collector/connector/countconnector | |||
gomod: go.opentelemetry.io/collector/connector/countconnector v0.0.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can it be v0.65.0, given that it will be replaced anyway?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
ConnectorSettings: config.NewConnectorSettings(component.NewID("nop")), | ||
} | ||
}, | ||
component.WithTracesToTracesConnector(createTracesToTracesConnector, component.StabilityLevelStable), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can't be stable yet, from what I remember. It should be Alpha.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch. Not intended.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
|
||
var ( | ||
errDataTypesNotSupported = "connection from %s to %s is not supported" | ||
ErrTracesToTraces = fmt.Errorf(errDataTypesNotSupported, DataTypeTraces, DataTypeTraces) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be clearer later on during the review, but should those be part of the public API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They are exported temporarily because the component
package tests are technically in another package component_test
.
// then process and export the metric to the appropriate backend. | ||
type MetricsToMetricsConnector interface { | ||
Connector | ||
ConsumeMetricsToMetrics(ctx context.Context, tm pmetric.Metrics) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll play around with this a bit, but I believe it might be possible to use generics here to alleviate at least part of the combinatorial problem.
}) | ||
|
||
conn := comp.Unwrap().(*countConnector) | ||
conn.metricsConsumer = nextConsumer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps I'm missing something, but if you have the same connector for different input signals and different output signals, won't the last output override the previous ones? Like:
logs -> connector/1 -> metrics/prometheus1
traces -> connector/1 -> metrics/prometheus2
In the current code, won't this end up with logs
sending data to prometheus2
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case, each instantiation of the connector would emit to both prometheus1
and prometheus2
because the connector is acting as a receiver in a pipeline with two exporters, or a receiver in two pipelines. Either way, the data should be fanned out to both. The service
package is responsible for detecting this relationship during build time and wrapping consumers into a fanoutconsumer
when appropriate.
connector/countconnector/count.go
Outdated
// it should be removed from this map so the same configuration can be recreated successfully. | ||
var connectors = sharedcomponent.NewSharedComponents() | ||
|
||
// otlpReceiver is the type that exposes Trace and Metrics reception. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
// otlpReceiver is the type that exposes Trace and Metrics reception. | |
// countConnector counts the input data points, converting to metrics |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
connector/countconnector/go.mod
Outdated
@@ -0,0 +1,55 @@ | |||
module go.opentelemetry.io/collector/connector/countconnector | |||
|
|||
go 1.19 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Before merging, can you ensure this is aligned with the main go.mod? I think we are still on 1.18
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
connector/countconnector/go.mod
Outdated
|
||
require ( | ||
github.com/stretchr/testify v1.8.1 | ||
go.opentelemetry.io/collector v0.65.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same for these :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
The `nopconnector` can be used to chain pipelines of the same type together. | ||
For example, it can replicate a signal to multiple pipelines so that each pipeline | ||
can process the signal independently in varying ways. Alternately, it can be used to | ||
merge two pipelines together so they can be processed as one pipeline. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A paragraph here with a couple of use-cases would be nice to have. I myself wouldn't know why I would need this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added
service/config.go
Outdated
} | ||
if cfg.Connectors[ref] != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here:
if cfg.Connectors[ref] != nil { | |
if _, ok := cfg.Connectors[ref]; ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
service/config.go
Outdated
@@ -124,9 +145,14 @@ func (cfg *Config) validateService() error { | |||
// Validate pipeline receiver name references. | |||
for _, ref := range pipeline.Receivers { | |||
// Check that the name referenced in the pipeline's receivers exists in the top-level receivers. | |||
if cfg.Receivers[ref] == nil { | |||
return fmt.Errorf("pipeline %q references receiver %q which does not exist", pipelineID, ref) | |||
if cfg.Receivers[ref] != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a performance gain if you use the optional second return value (bool) from the map lookup?
if cfg.Receivers[ref] != nil { | |
if _, ok := cfg.Receivers[ref]; ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I think your suggestion is more idiomatic either way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
service/config.go
Outdated
@@ -152,15 +178,33 @@ func (cfg *Config) validateService() error { | |||
// Validate pipeline exporter name references. | |||
for _, ref := range pipeline.Exporters { | |||
// Check that the name referenced in the pipeline's Exporters exists in the top-level Exporters. | |||
if cfg.Exporters[ref] == nil { | |||
return fmt.Errorf("pipeline %q references exporter %q which does not exist", pipelineID, ref) | |||
if cfg.Exporters[ref] != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And here?
if cfg.Exporters[ref] != nil { | |
if _, ok := cfg.Exporters[ref]; ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
service/config.go
Outdated
if cfg.Exporters[ref] != nil { | ||
continue | ||
} | ||
if cfg.Connectors[ref] != nil { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here as well:
if cfg.Connectors[ref] != nil { | |
if _, ok := cfg.Connectors[ref]; ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed
d6d8bd6
to
846b039
Compare
e733098
to
53f526b
Compare
53f526b
to
3c41db9
Compare
Closing in favor of #6700 |
2nd iteration of connectors prototype. (Follows #6140)
This PR does the following:
service/internal/pipelines/Pipelines
to an interface with two implementations. The new implementation is available behind a feature gate called:service.enableConnectors
nopconnector
,countconnector
)Here is a sample configuration that uses both the
nop
andcount
connectors to accomplish a couple simple tasks:To run this, make sure to enable the feature gate: