diff --git a/coderd/coderd.go b/coderd/coderd.go index 9115888fc566b..3096b07c72f50 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/oauth2provider" "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/andybalholm/brotli" @@ -198,6 +199,7 @@ type Options struct { TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] + UsageCollector *atomic.Pointer[usage.Collector] // CoordinatorResumeTokenProvider is used to provide and validate resume // tokens issued by and passed to the coordinator DRPC API. CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider @@ -426,6 +428,13 @@ func New(options *Options) *API { v := schedule.NewAGPLUserQuietHoursScheduleStore() options.UserQuietHoursScheduleStore.Store(&v) } + if options.UsageCollector == nil { + options.UsageCollector = &atomic.Pointer[usage.Collector]{} + } + if options.UsageCollector.Load() == nil { + collector := usage.NewAGPLCollector() + options.UsageCollector.Store(&collector) + } if options.OneTimePasscodeValidityPeriod == 0 { options.OneTimePasscodeValidityPeriod = 20 * time.Minute } @@ -588,6 +597,7 @@ func New(options *Options) *API { UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, AccessControlStore: options.AccessControlStore, BuildUsageChecker: &buildUsageChecker, + UsageCollector: options.UsageCollector, FileCache: files.New(options.PrometheusRegistry, options.Authorizer), Experiments: experiments, WebpushDispatcher: options.WebPushDispatcher, @@ -1662,6 +1672,9 @@ type API struct { // BuildUsageChecker is a pointer as it's passed around to multiple // components. BuildUsageChecker *atomic.Pointer[wsbuilder.UsageChecker] + // UsageCollector is a pointer to an atomic pointer because it is passed to + // multiple components. + UsageCollector *atomic.Pointer[usage.Collector] UpdatesProvider tailnet.WorkspaceUpdatesProvider @@ -1877,6 +1890,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n &api.Auditor, api.TemplateScheduleStore, api.UserQuietHoursScheduleStore, + api.UsageCollector, api.DeploymentValues, provisionerdserver.Options{ OIDCConfig: api.OIDCConfig, diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 518b48d2fe04b..ef34dd1719be7 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -29,6 +29,7 @@ import ( "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk/drpcsdk" @@ -121,6 +122,7 @@ type server struct { DeploymentValues *codersdk.DeploymentValues NotificationsEnqueuer notifications.Enqueuer PrebuildsOrchestrator *atomic.Pointer[prebuilds.ReconciliationOrchestrator] + UsageCollector *atomic.Pointer[usage.Collector] OIDCConfig promoauth.OAuth2Config @@ -174,6 +176,7 @@ func NewServer( auditor *atomic.Pointer[audit.Auditor], templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore], userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore], + usageCollector *atomic.Pointer[usage.Collector], deploymentValues *codersdk.DeploymentValues, options Options, enqueuer notifications.Enqueuer, @@ -195,6 +198,9 @@ func NewServer( if userQuietHoursScheduleStore == nil { return nil, xerrors.New("userQuietHoursScheduleStore is nil") } + if usageCollector == nil { + return nil, xerrors.New("usageCollector is nil") + } if deploymentValues == nil { return nil, xerrors.New("deploymentValues is nil") } @@ -244,6 +250,7 @@ func NewServer( heartbeatInterval: options.HeartbeatInterval, heartbeatFn: options.HeartbeatFn, PrebuildsOrchestrator: prebuildsOrchestrator, + UsageCollector: usageCollector, } if s.heartbeatFn == nil { @@ -1892,6 +1899,18 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } sidebarAppID = uuid.NullUUID{UUID: id, Valid: true} + + // Collect usage event for managed agents. + usageCollector := s.UsageCollector.Load() + if usageCollector != nil { + event := usage.DCManagedAgentsV1{ + Count: 1, + } + err = (*usageCollector).CollectDiscreteUsageEvent(ctx, db, event) + if err != nil { + return xerrors.Errorf("collect %q event: %w", event.EventType(), err) + } + } } // Regardless of whether there is an AI task or not, update the field to indicate one way or the other since it diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 66684835650a8..6ed6072518923 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -44,6 +44,7 @@ import ( "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/wspubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/agentsdk" @@ -67,6 +68,13 @@ func testUserQuietHoursScheduleStore() *atomic.Pointer[schedule.UserQuietHoursSc return ptr } +func testUsageCollector() *atomic.Pointer[usage.Collector] { + ptr := &atomic.Pointer[usage.Collector]{} + collector := usage.NewAGPLCollector() + ptr.Store(&collector) + return ptr +} + func TestAcquireJob_LongPoll(t *testing.T) { t.Parallel() //nolint:dogsled @@ -2469,7 +2477,10 @@ func TestCompleteJob(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - srv, db, _, pd := setup(t, false, &overrides{}) + fakeUsageCollector, usageCollectorPtr := newFakeUsageCollector() + srv, db, _, pd := setup(t, false, &overrides{ + usageCollector: usageCollectorPtr, + }) importJobID := uuid.New() tvID := uuid.New() @@ -2535,6 +2546,10 @@ func TestCompleteJob(t *testing.T) { require.NoError(t, err) require.True(t, version.HasAITask.Valid) // We ALWAYS expect a value to be set, therefore not nil, i.e. valid = true. require.Equal(t, tc.expected, version.HasAITask.Bool) + + // We never expect a usage event to be collected for + // template imports. + require.Empty(t, fakeUsageCollector.collectedEvents) }) } }) @@ -2576,7 +2591,10 @@ func TestCompleteJob(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - srv, db, _, pd := setup(t, false, &overrides{}) + fakeUsageCollector, usageCollectorPtr := newFakeUsageCollector() + srv, db, _, pd := setup(t, false, &overrides{ + usageCollector: usageCollectorPtr, + }) importJobID := uuid.New() tvID := uuid.New() @@ -2657,6 +2675,15 @@ func TestCompleteJob(t *testing.T) { if tc.expected { require.Equal(t, sidebarAppID, build.AITaskSidebarAppID.UUID.String()) + + // Check that a usage event was collected. + require.Len(t, fakeUsageCollector.collectedEvents, 1) + require.Equal(t, usage.DCManagedAgentsV1{ + Count: 1, + }, fakeUsageCollector.collectedEvents[0]) + } else { + // Check that no usage event was collected. + require.Empty(t, fakeUsageCollector.collectedEvents) } }) } @@ -3582,6 +3609,7 @@ type overrides struct { externalAuthConfigs []*externalauth.Config templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + usageCollector *atomic.Pointer[usage.Collector] clock *quartz.Mock acquireJobLongPollDuration time.Duration heartbeatFn func(ctx context.Context) error @@ -3603,6 +3631,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi var externalAuthConfigs []*externalauth.Config tss := testTemplateScheduleStore() uqhss := testUserQuietHoursScheduleStore() + usageCollector := testUsageCollector() clock := quartz.NewReal() pollDur := time.Duration(0) if ov == nil { @@ -3640,6 +3669,15 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi require.True(t, swapped) } } + if ov.usageCollector != nil { + tusageCollector := usageCollector.Load() + // keep the initial test value if the override hasn't set the atomic pointer. + usageCollector = ov.usageCollector + if usageCollector.Load() == nil { + swapped := usageCollector.CompareAndSwap(nil, tusageCollector) + require.True(t, swapped) + } + } if ov.clock != nil { clock = ov.clock } @@ -3695,6 +3733,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi auditPtr, tss, uqhss, + usageCollector, deploymentValues, provisionerdserver.Options{ ExternalAuthConfigs: externalAuthConfigs, @@ -3809,3 +3848,22 @@ func (s *fakeStream) cancel() { s.canceled = true s.c.Broadcast() } + +type fakeUsageCollector struct { + collectedEvents []usage.Event +} + +var _ usage.Collector = &fakeUsageCollector{} + +func newFakeUsageCollector() (*fakeUsageCollector, *atomic.Pointer[usage.Collector]) { + ptr := &atomic.Pointer[usage.Collector]{} + fake := &fakeUsageCollector{} + var collector usage.Collector = fake + ptr.Store(&collector) + return fake, ptr +} + +func (f *fakeUsageCollector) CollectDiscreteUsageEvent(_ context.Context, _ database.Store, event usage.DiscreteEvent) error { + f.collectedEvents = append(f.collectedEvents, event) + return nil +} diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 3b1fd63ab1c4c..23047ae622e6f 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -20,6 +20,7 @@ import ( "github.com/coder/coder/v2/enterprise/audit/backends" "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/dormancy" + "github.com/coder/coder/v2/enterprise/coderd/usage" "github.com/coder/coder/v2/enterprise/dbcrypt" "github.com/coder/coder/v2/enterprise/trialer" "github.com/coder/coder/v2/tailnet" @@ -116,11 +117,33 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { o.ExternalTokenEncryption = cs } + if o.LicenseKeys == nil { + o.LicenseKeys = coderd.Keys + } + + multiCloser := &multiCloser{} + + // Create the enterprise API. api, err := coderd.New(ctx, o) if err != nil { return nil, nil, err } - return api.AGPL, api, nil + multiCloser.Add(api) + + // Start the enterprise usage publisher routine. This won't do anything + // unless the deployment is licensed and one of the licenses has usage + // publishing enabled. + publisher := usage.NewTallymanPublisher(ctx, options.Logger, options.Database, o.LicenseKeys, + usage.PublisherWithHTTPClient(api.HTTPClient), + ) + err = publisher.Start() + if err != nil { + _ = multiCloser.Close() + return nil, nil, xerrors.Errorf("start usage publisher: %w", err) + } + multiCloser.Add(publisher) + + return api.AGPL, multiCloser, nil }) cmd.AddSubcommands( @@ -128,3 +151,23 @@ func (r *RootCmd) Server(_ func()) *serpent.Command { ) return cmd } + +type multiCloser struct { + closers []io.Closer +} + +var _ io.Closer = &multiCloser{} + +func (m *multiCloser) Add(closer io.Closer) { + m.closers = append(m.closers, closer) +} + +func (m *multiCloser) Close() error { + var mErr error + for _, closer := range m.closers { + if err := closer.Close(); err != nil { + mErr = xerrors.Errorf("close %T: %w", closer, err) + } + } + return mErr +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 9583e14cd7fd3..f4fd78ec006ac 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/coder/quartz" @@ -22,10 +23,12 @@ import ( agplportsharing "github.com/coder/coder/v2/coderd/portsharing" agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" "github.com/coder/coder/v2/coderd/rbac/policy" + agplusage "github.com/coder/coder/v2/coderd/usage" "github.com/coder/coder/v2/coderd/wsbuilder" "github.com/coder/coder/v2/enterprise/coderd/connectionlog" "github.com/coder/coder/v2/enterprise/coderd/enidpsync" "github.com/coder/coder/v2/enterprise/coderd/portsharing" + "github.com/coder/coder/v2/enterprise/coderd/usage" "golang.org/x/xerrors" "tailscale.com/tailcfg" @@ -90,6 +93,13 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Entitlements == nil { options.Entitlements = entitlements.New() } + if options.Options.UsageCollector == nil { + options.Options.UsageCollector = &atomic.Pointer[agplusage.Collector]{} + } + if options.Options.UsageCollector.Load() == nil { + collector := usage.NewDBCollector() + options.Options.UsageCollector.Store(&collector) + } ctx, cancelFunc := context.WithCancel(ctx) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c8304952781d1..1d0f70dc92c9d 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -352,6 +352,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) &api.AGPL.Auditor, api.AGPL.TemplateScheduleStore, api.AGPL.UserQuietHoursScheduleStore, + api.AGPL.UsageCollector, api.DeploymentValues, provisionerdserver.Options{ ExternalAuthConfigs: api.ExternalAuthConfigs, diff --git a/enterprise/coderd/usage/collector.go b/enterprise/coderd/usage/collector.go index 99dcef6d10217..e25ee76710190 100644 --- a/enterprise/coderd/usage/collector.go +++ b/enterprise/coderd/usage/collector.go @@ -13,17 +13,17 @@ import ( "github.com/coder/quartz" ) -// Collector collects usage events and stores them in the database for +// dbCollector collects usage events and stores them in the database for // publishing. -type Collector struct { +type dbCollector struct { clock quartz.Clock } -var _ agplusage.Collector = &Collector{} +var _ agplusage.Collector = &dbCollector{} -// NewCollector creates a new database-backed usage event collector. -func NewCollector(opts ...CollectorOption) *Collector { - c := &Collector{ +// NewDBCollector creates a new database-backed usage event collector. +func NewDBCollector(opts ...CollectorOption) agplusage.Collector { + c := &dbCollector{ clock: quartz.NewReal(), } for _, opt := range opts { @@ -32,17 +32,17 @@ func NewCollector(opts ...CollectorOption) *Collector { return c } -type CollectorOption func(*Collector) +type CollectorOption func(*dbCollector) // CollectorWithClock sets the quartz clock to use for the collector. func CollectorWithClock(clock quartz.Clock) CollectorOption { - return func(c *Collector) { + return func(c *dbCollector) { c.clock = clock } } // CollectDiscreteUsageEvent implements agplusage.Collector. -func (c *Collector) CollectDiscreteUsageEvent(ctx context.Context, db database.Store, event agplusage.DiscreteEvent) error { +func (c *dbCollector) CollectDiscreteUsageEvent(ctx context.Context, db database.Store, event agplusage.DiscreteEvent) error { if !event.EventType().IsDiscrete() { return xerrors.Errorf("event type %q is not a discrete event", event.EventType()) } diff --git a/enterprise/coderd/usage/collector_test.go b/enterprise/coderd/usage/collector_test.go index 6aa7bc605788b..85c323c864db1 100644 --- a/enterprise/coderd/usage/collector_test.go +++ b/enterprise/coderd/usage/collector_test.go @@ -28,7 +28,7 @@ func TestCollector(t *testing.T) { ctrl := gomock.NewController(t) db := dbmock.NewMockStore(ctrl) clock := quartz.NewMock(t) - collector := usage.NewCollector(usage.CollectorWithClock(clock)) + collector := usage.NewDBCollector(usage.CollectorWithClock(clock)) now := dbtime.Now() events := []struct { @@ -76,7 +76,7 @@ func TestCollector(t *testing.T) { db := dbmock.NewMockStore(ctrl) // We should get an error if the event is invalid. - collector := usage.NewCollector() + collector := usage.NewDBCollector() err := collector.CollectDiscreteUsageEvent(ctx, db, agplusage.DCManagedAgentsV1{ Count: 0, // invalid }) diff --git a/enterprise/coderd/usage/publisher.go b/enterprise/coderd/usage/publisher.go index 290691e44c4ed..57b41cbc9cbb5 100644 --- a/enterprise/coderd/usage/publisher.go +++ b/enterprise/coderd/usage/publisher.go @@ -17,7 +17,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/cryptorand" - "github.com/coder/coder/v2/enterprise/coderd" "github.com/coder/coder/v2/enterprise/coderd/license" "github.com/coder/quartz" ) @@ -47,17 +46,17 @@ type Publisher interface { } type tallymanPublisher struct { - ctx context.Context - ctxCancel context.CancelFunc - log slog.Logger - db database.Store - done chan struct{} + ctx context.Context + ctxCancel context.CancelFunc + log slog.Logger + db database.Store + licenseKeys map[string]ed25519.PublicKey + done chan struct{} // Configured with options: ingestURL string httpClient *http.Client clock quartz.Clock - licenseKeys map[string]ed25519.PublicKey initialDelay time.Duration } @@ -65,19 +64,19 @@ var _ Publisher = &tallymanPublisher{} // NewTallymanPublisher creates a Publisher that publishes usage events to // Coder's Tallyman service. -func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Store, opts ...TallymanPublisherOption) Publisher { +func NewTallymanPublisher(ctx context.Context, log slog.Logger, db database.Store, keys map[string]ed25519.PublicKey, opts ...TallymanPublisherOption) Publisher { ctx, cancel := context.WithCancel(ctx) publisher := &tallymanPublisher{ - ctx: ctx, - ctxCancel: cancel, - log: log, - db: db, - done: make(chan struct{}), + ctx: ctx, + ctxCancel: cancel, + log: log, + db: db, + licenseKeys: keys, + done: make(chan struct{}), - ingestURL: tallymanIngestURLV1, - httpClient: http.DefaultClient, - clock: quartz.NewReal(), - licenseKeys: coderd.Keys, + ingestURL: tallymanIngestURLV1, + httpClient: http.DefaultClient, + clock: quartz.NewReal(), } for _, opt := range opts { opt(publisher) @@ -90,6 +89,9 @@ type TallymanPublisherOption func(*tallymanPublisher) // PublisherWithHTTPClient sets the HTTP client to use for publishing usage events. func PublisherWithHTTPClient(httpClient *http.Client) TallymanPublisherOption { return func(p *tallymanPublisher) { + if httpClient == nil { + httpClient = http.DefaultClient + } p.httpClient = httpClient } } @@ -101,14 +103,6 @@ func PublisherWithClock(clock quartz.Clock) TallymanPublisherOption { } } -// PublisherWithLicenseKeys sets the license public keys to use for license -// validation. -func PublisherWithLicenseKeys(keys map[string]ed25519.PublicKey) TallymanPublisherOption { - return func(p *tallymanPublisher) { - p.licenseKeys = keys - } -} - // PublisherWithIngestURL sets the ingest URL to use for publishing usage // events. func PublisherWithIngestURL(ingestURL string) TallymanPublisherOption { @@ -146,6 +140,10 @@ func (p *tallymanPublisher) Start() error { p.initialDelay = tallymanPublishInitialMinimumDelay + time.Duration(plusDelay) } + if len(p.licenseKeys) == 0 { + return xerrors.New("no license keys provided") + } + go p.publishLoop(p.ctx, deploymentUUID) return nil } diff --git a/enterprise/coderd/usage/publisher_test.go b/enterprise/coderd/usage/publisher_test.go index 5e2579a4089ba..96c25a6eb4ee2 100644 --- a/enterprise/coderd/usage/publisher_test.go +++ b/enterprise/coderd/usage/publisher_test.go @@ -61,7 +61,7 @@ func TestIntegration(t *testing.T) { return handler(req) })) - collector := usage.NewCollector( + collector := usage.NewDBCollector( usage.CollectorWithClock(clock), ) // Insert an old event that should never be published. @@ -81,10 +81,9 @@ func TestIntegration(t *testing.T) { require.NoErrorf(t, err, "collecting event %d", i) } - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -213,10 +212,9 @@ func TestPublisherNoEligibleLicenses(t *testing.T) { } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -284,14 +282,13 @@ func TestPublisherClaimExpiry(t *testing.T) { return tallymanAcceptAllHandler(req) })) - collector := usage.NewCollector( + collector := usage.NewDBCollector( usage.CollectorWithClock(clock), ) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), usage.PublisherWithInitialDelay(17*time.Minute), ) defer publisher.Close() @@ -368,10 +365,9 @@ func TestPublisherMissingEvents(t *testing.T) { } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) // Expect the publisher to call SelectUsageEventsForPublishing, followed by @@ -486,10 +482,9 @@ func TestPublisherLicenseSelection(t *testing.T) { return tallymanAcceptAllHandler(req) })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() @@ -555,10 +550,9 @@ func TestPublisherTallymanError(t *testing.T) { } })) - publisher := usage.NewTallymanPublisher(ctx, log, db, + publisher := usage.NewTallymanPublisher(ctx, log, db, coderdenttest.Keys, usage.PublisherWithClock(clock), usage.PublisherWithIngestURL(ingestURL), - usage.PublisherWithLicenseKeys(coderdenttest.Keys), ) defer publisher.Close() pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy