Compare commits

..

3 Commits

Author SHA1 Message Date
Vikrant Gupta
c6b2fe47d5 feat(web): add support for sentry and pylon (#11495)
Some checks are pending
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): add support for sentry and pylon

* feat(web): removed bootdata.ts and maintaining websettings as single source of truth

* feat(web): update the example.yaml

---------

Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2026-05-29 12:08:00 +00:00
Tushar Vats
1d6fa6e507 feat: extend error fields (#11228)
* fix: extended error fields

* fix: remove stale comment

* fix: removed retry policy enum and added example

* fix: generate openapi

* fix: stale comments
2026-05-29 11:43:35 +00:00
swapnil-signoz
910645516d chore: remove cloud integration service cascade delete constraint (#11480)
* chore: remove cloud integration service cascade delete constraint

* refactor: manually recreate table
2026-05-29 11:21:05 +00:00
29 changed files with 632 additions and 346 deletions

View File

@@ -68,6 +68,12 @@ web:
appcues:
# Whether to enable Appcues in web.
enabled: true
sentry:
# Whether to enable Sentry in web.
enabled: true
pylon:
# Whether to enable Pylon in web.
enabled: true
##################### Cache #####################
cache:

View File

@@ -3237,8 +3237,20 @@ components:
items:
$ref: '#/components/schemas/ErrorsResponseerroradditional'
type: array
invalidReferences:
items:
type: string
type: array
message:
type: string
retry:
$ref: '#/components/schemas/ErrorsResponseretryjson'
suggestions:
items:
type: string
type: array
type:
type: string
url:
type: string
required:
@@ -3250,6 +3262,11 @@ components:
message:
type: string
type: object
ErrorsResponseretryjson:
properties:
delay:
$ref: '#/components/schemas/TimeDuration'
type: object
FactoryResponse:
properties:
healthy:
@@ -7068,11 +7085,6 @@ components:
required:
- name
type: object
TypesPostableVerifyResetPasswordToken:
properties:
token:
type: string
type: object
TypesResetPasswordToken:
properties:
expiresAt:
@@ -14854,41 +14866,6 @@ paths:
summary: Readiness check
tags:
- health
/api/v2/reset_password_tokens/verify:
post:
deprecated: false
description: This endpoint verifies whether a reset password token exists and
is not expired
operationId: VerifyResetPasswordToken
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TypesPostableVerifyResetPasswordToken'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
summary: Verify a reset password token
tags:
- users
/api/v2/roles/{id}/users:
get:
deprecated: false

View File

@@ -1,7 +1,9 @@
{
"required": [
"posthog",
"appcues"
"appcues",
"sentry",
"pylon"
],
"additionalProperties": false,
"definitions": {
@@ -28,6 +30,30 @@
}
},
"type": "object"
},
"Pylon": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
},
"Sentry": {
"required": [
"enabled"
],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
},
"type": "object"
}
},
"properties": {
@@ -36,6 +62,12 @@
},
"posthog": {
"$ref": "#/definitions/Posthog"
},
"pylon": {
"$ref": "#/definitions/Pylon"
},
"sentry": {
"$ref": "#/definitions/Sentry"
}
},
"type": "object"

View File

@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { extractDomain } from 'utils/app';
import { bootSettings } from 'utils/bootData';
import { Home } from './pageComponents';
import PrivateRoute from './Private';
@@ -334,7 +333,10 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
if (
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
process.env.POSTHOG_KEY
) {
posthog.init(process.env.POSTHOG_KEY, {
api_host: 'https://us.i.posthog.com',
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well

View File

@@ -2051,6 +2051,10 @@ export interface ErrorsResponseerroradditionalDTO {
message?: string;
}
export interface ErrorsResponseretryjsonDTO {
delay?: TimeDurationDTO;
}
export interface ErrorsJSONDTO {
/**
* @type string
@@ -2060,10 +2064,23 @@ export interface ErrorsJSONDTO {
* @type array
*/
errors?: ErrorsResponseerroradditionalDTO[];
/**
* @type array
*/
invalidReferences?: string[];
/**
* @type string
*/
message: string;
retry?: ErrorsResponseretryjsonDTO;
/**
* @type array
*/
suggestions?: string[];
/**
* @type string
*/
type?: string;
/**
* @type string
*/
@@ -8308,13 +8325,6 @@ export interface TypesPostableRoleDTO {
name: string;
}
export interface TypesPostableVerifyResetPasswordTokenDTO {
/**
* @type string
*/
token?: string;
}
export interface TypesResetPasswordTokenDTO {
/**
* @type string

View File

@@ -48,7 +48,6 @@ import type {
TypesPostableInviteDTO,
TypesPostableResetPasswordDTO,
TypesPostableRoleDTO,
TypesPostableVerifyResetPasswordTokenDTO,
TypesUpdatableUserDTO,
UpdateUserDeprecated200,
UpdateUserDeprecatedPathParameters,
@@ -948,90 +947,6 @@ export const useForgotPassword = <
> => {
return useMutation(getForgotPasswordMutationOptions(options));
};
/**
* This endpoint verifies whether a reset password token exists and is not expired
* @summary Verify a reset password token
*/
export const verifyResetPasswordToken = (
typesPostableVerifyResetPasswordTokenDTO?: BodyType<TypesPostableVerifyResetPasswordTokenDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/reset_password_tokens/verify`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: typesPostableVerifyResetPasswordTokenDTO,
signal,
});
};
export const getVerifyResetPasswordTokenMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
const mutationKey = ['verifyResetPasswordToken'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> }
> = (props) => {
const { data } = props ?? {};
return verifyResetPasswordToken(data);
};
return { mutationFn, ...mutationOptions };
};
export type VerifyResetPasswordTokenMutationResult = NonNullable<
Awaited<ReturnType<typeof verifyResetPasswordToken>>
>;
export type VerifyResetPasswordTokenMutationBody =
| BodyType<TypesPostableVerifyResetPasswordTokenDTO>
| undefined;
export type VerifyResetPasswordTokenMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Verify a reset password token
*/
export const useVerifyResetPasswordToken = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof verifyResetPasswordToken>>,
TError,
{ data?: BodyType<TypesPostableVerifyResetPasswordTokenDTO> },
TContext
> => {
return useMutation(getVerifyResetPasswordTokenMutationOptions(options));
};
/**
* This endpoint returns the users having the role by role id
* @summary Get users by role id

View File

@@ -3,6 +3,8 @@
export interface WebSettings {
appcues: Appcues;
posthog: Posthog;
pylon: Pylon;
sentry: Sentry;
}
export interface Appcues {
enabled: boolean;
@@ -10,3 +12,9 @@ export interface Appcues {
export interface Posthog {
enabled: boolean;
}
export interface Pylon {
enabled: boolean;
}
export interface Sentry {
enabled: boolean;
}

View File

@@ -1,74 +0,0 @@
export {};
type BootData = typeof import('../bootData');
function loadModule(settings?: object | null): BootData {
(window as any).signozBootData =
settings !== undefined ? { settings } : undefined;
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
return mod;
}
afterEach(() => {
delete (window as any).signozBootData;
});
describe('when window.signozBootData is absent', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule();
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is null (injection failed)', () => {
it('defaults posthog and appcues to enabled', () => {
const { bootSettings } = loadModule(null);
expect(bootSettings.posthog.enabled).toBe(true);
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData.settings is populated', () => {
it('reads posthog enabled: true', () => {
const { bootSettings } = loadModule({ posthog: { enabled: true } });
expect(bootSettings.posthog.enabled).toBe(true);
});
it('reads posthog enabled: false', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.posthog.enabled).toBe(false);
});
it('reads appcues enabled: true', () => {
const { bootSettings } = loadModule({ appcues: { enabled: true } });
expect(bootSettings.appcues.enabled).toBe(true);
});
it('reads appcues enabled: false', () => {
const { bootSettings } = loadModule({ appcues: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(false);
});
it('missing sub-namespace defaults to enabled', () => {
const { bootSettings } = loadModule({ posthog: { enabled: false } });
expect(bootSettings.appcues.enabled).toBe(true);
});
});
describe('when window.signozBootData exists but settings is undefined', () => {
it('defaults posthog and appcues to enabled', () => {
(window as any).signozBootData = {};
let mod!: BootData;
jest.isolateModules(() => {
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
mod = require('../bootData');
});
expect(mod.bootSettings.posthog.enabled).toBe(true);
expect(mod.bootSettings.appcues.enabled).toBe(true);
});
});

View File

@@ -1,11 +0,0 @@
import type { WebSettings } from 'types/generated/webSettings';
const raw = window.signozBootData?.settings as
| Partial<WebSettings>
| null
| undefined;
export const bootSettings: Readonly<WebSettings> = {
posthog: { enabled: raw?.posthog?.enabled ?? true },
appcues: { enabled: raw?.appcues?.enabled ?? true },
};

View File

@@ -264,23 +264,6 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/reset_password_tokens/verify", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.VerifyResetPasswordToken), handler.OpenAPIDef{
ID: "VerifyResetPasswordToken",
Tags: []string{"users"},
Summary: "Verify a reset password token",
Description: "This endpoint verifies whether a reset password token exists and is not expired",
Request: new(types.PostableVerifyResetPasswordToken),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: []handler.OpenAPISecurityScheme{},
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/resetPassword", handler.New(provider.authzMiddleware.OpenAccess(provider.userHandler.ResetPassword), handler.OpenAPIDef{
ID: "ResetPassword",
Tags: []string{"users"},

View File

@@ -4,12 +4,12 @@ import (
"errors" //nolint:depguard
"fmt"
"log/slog"
"time"
"go.opentelemetry.io/otel/attribute"
)
// base is the fundamental struct that implements the error interface.
// The order of the struct is 'TCMEUAS'.
type base struct {
// t denotes the custom type of the error.
t typ
@@ -25,6 +25,14 @@ type base struct {
a []string
// s contains the stacktrace captured at error creation time.
s fmt.Stringer
// r is the retry strategy for the error, if applicable.
r *retry
// suggestions is a list of user-facing suggestions related to the error, if present.
// For example, narrow the time range window or typo suggestion
suggestions []string
// invalidReferences is a list of references that were invalid and contributed to the error, if present.
// For example, a typo from user avg(sum), we return invalidRefences: ['sum']
invalidReferences []string
}
// Stacktrace returns the stacktrace captured at error creation time, formatted as a string.
@@ -39,13 +47,16 @@ func (b *base) Stacktrace() string {
// and returns a new base error.
func (b *base) WithStacktrace(s string) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: b.a,
s: rawStacktrace(s),
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: b.a,
s: rawStacktrace(s),
r: b.r,
suggestions: b.suggestions,
invalidReferences: b.invalidReferences,
}
}
@@ -113,13 +124,16 @@ func WithAdditionalf(cause error, format string, args ...any) *base {
s = original.s
}
b := &base{
t: t,
c: c,
m: m,
e: e,
u: u,
a: a,
s: s,
t: t,
c: c,
m: m,
e: e,
u: u,
a: a,
s: s,
r: retryOf(cause),
suggestions: suggestionsOf(cause),
invalidReferences: invalidReferencesOf(cause),
}
return b.WithAdditional(append(a, fmt.Sprintf(format, args...))...)
@@ -128,29 +142,88 @@ func WithAdditionalf(cause error, format string, args ...any) *base {
// WithUrl adds a url to the base error and returns a new base error.
func (b *base) WithUrl(u string) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: u,
a: b.a,
s: b.s,
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: u,
a: b.a,
s: b.s,
r: b.r,
suggestions: b.suggestions,
invalidReferences: b.invalidReferences,
}
}
// WithAdditional adds additional messages to the base error and returns a new base error.
func (b *base) WithAdditional(a ...string) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: a,
s: b.s,
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: a,
s: b.s,
r: b.r,
suggestions: b.suggestions,
invalidReferences: b.invalidReferences,
}
}
// withRetry adds retry metadata to the base error and returns a new base error.
func (b *base) withRetry(r retry) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: b.a,
s: b.s,
r: &r,
suggestions: b.suggestions,
invalidReferences: b.invalidReferences,
}
}
// WithSuggestions replaces the list of suggestions on the base error.
func (b *base) WithSuggestions(suggestions ...string) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: b.a,
s: b.s,
r: b.r,
suggestions: suggestions,
invalidReferences: b.invalidReferences,
}
}
// WithInvalidReferences replaces the list of invalid references on the base error.
func (b *base) WithInvalidReferences(invalidReferences ...string) *base {
return &base{
t: b.t,
c: b.c,
m: b.m,
e: b.e,
u: b.u,
a: b.a,
s: b.s,
r: b.r,
suggestions: b.suggestions,
invalidReferences: invalidReferences,
}
}
// WithRetryAfter sets the retry delay on the base error and returns a new base error.
func (b *base) WithRetryAfter(delay time.Duration) *base {
return b.withRetry(newRetryAfter(delay))
}
// Unwrapb is a combination of built-in errors.As and type casting.
// It finds the first error in cause that matches base,
// and if one is found, returns the individual fields of base.
@@ -226,16 +299,6 @@ func NewInvalidInputf(code Code, format string, args ...any) *base {
return Newf(TypeInvalidInput, code, format, args...)
}
// WrapUnexpectedf is a wrapper around Wrapf with TypeUnexpected.
func WrapUnexpectedf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeInvalidInput, code, format, args...)
}
// NewUnexpectedf is a wrapper around Newf with TypeUnexpected.
func NewUnexpectedf(code Code, format string, args ...any) *base {
return Newf(TypeInvalidInput, code, format, args...)
}
// NewMethodNotAllowedf is a wrapper around Newf with TypeMethodNotAllowed.
func NewMethodNotAllowedf(code Code, format string, args ...any) *base {
return Newf(TypeMethodNotAllowed, code, format, args...)
@@ -251,6 +314,26 @@ func NewTimeoutf(code Code, format string, args ...any) *base {
return Newf(TypeTimeout, code, format, args...)
}
// WrapUnauthenticatedf is a wrapper around Wrapf with TypeUnauthenticated.
func WrapUnauthenticatedf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeUnauthenticated, code, format, args...)
}
// NewUnauthenticatedf is a wrapper around Newf with TypeUnauthenticated.
func NewUnauthenticatedf(code Code, format string, args ...any) *base {
return Newf(TypeUnauthenticated, code, format, args...)
}
// WrapForbiddenf is a wrapper around Wrapf with TypeForbidden.
func WrapForbiddenf(cause error, code Code, format string, args ...any) *base {
return Wrapf(cause, TypeForbidden, code, format, args...)
}
// NewForbiddenf is a wrapper around Newf with TypeForbidden.
func NewForbiddenf(code Code, format string, args ...any) *base {
return Newf(TypeForbidden, code, format, args...)
}
// Attr returns an slog.Attr with a standardized "exception" key for the given error.
func Attr(err error) slog.Attr {
return slog.Any("exception", err)
@@ -262,3 +345,37 @@ func TypeAttr(err error) attribute.KeyValue {
t, _, _, _, _, _ := Unwrapb(err)
return attribute.String("error.type", t.String())
}
// RetryDelayOf returns the explicit retry delay set via WithRetryAfter,
// or zero if the error carries no retry delay.
func RetryDelayOf(err error) time.Duration {
base, ok := err.(*base)
if !ok || base.r == nil {
return 0
}
return base.r.delay
}
func retryOf(err error) *retry {
base, ok := err.(*base)
if ok {
return base.r
}
return nil
}
func suggestionsOf(err error) []string {
base, ok := err.(*base)
if ok {
return base.suggestions
}
return nil
}
func invalidReferencesOf(err error) []string {
base, ok := err.(*base)
if ok {
return base.invalidReferences
}
return nil
}

View File

@@ -3,8 +3,10 @@ package errors
import (
"errors" //nolint:depguard
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
@@ -59,6 +61,95 @@ func TestAttr(t *testing.T) {
assert.Equal(t, err, attr.Value.Any())
}
func TestWithSuggestions(t *testing.T) {
err := New(TypeInternal, MustNewCode("test_code"), "test error").WithSuggestions("try this")
assert.Equal(t, []string{"try this"}, suggestionsOf(err))
// WithSuggestions replaces the existing list.
err = err.WithSuggestions("try this instead")
assert.Equal(t, []string{"try this instead"}, suggestionsOf(err))
// Variadic form replaces with multiple entries.
err = err.WithSuggestions("first", "second")
assert.Equal(t, []string{"first", "second"}, suggestionsOf(err))
}
func TestWithRetryAfter(t *testing.T) {
err := New(TypeInternal, MustNewCode("test_code"), "test error").WithRetryAfter(5 * time.Microsecond)
r := retryOf(err)
assert.Equal(t, 5, int(r.delay.Microseconds()))
}
func TestWithInvalidReferences(t *testing.T) {
// WithInvalidReferences populates the list.
err := New(TypeInvalidInput, MustNewCode("bad_ref"), "bad ref").
WithInvalidReferences("queries[0]", "queries[1]")
assert.Equal(t, []string{"queries[0]", "queries[1]"}, invalidReferencesOf(err))
// WithInvalidReferences replaces the entire list on each call.
err = err.WithInvalidReferences("queries[2]")
assert.Equal(t, []string{"queries[2]"}, invalidReferencesOf(err),
"WithInvalidReferences must replace the entire list")
}
func TestAsJSONBaseError(t *testing.T) {
err := New(TypeInvalidInput, MustNewCode("bad_input"), "field foo is bad").
WithUrl("https://docs/bad_input").
WithAdditional("hint1", "hint2").
WithSuggestions("try this").
WithInvalidReferences("queries[0]")
j := AsJSON(err)
assert.Equal(t, "invalid-input", j.Type)
assert.Equal(t, "bad_input", j.Code)
assert.Equal(t, "field foo is bad", j.Message)
assert.Equal(t, "https://docs/bad_input", j.Url)
assert.Equal(t, []responseerroradditional{{Message: "hint1"}, {Message: "hint2"}}, j.Errors)
// InvalidInput auto-applies the after_fix policy via NewInvalidInputf — but
// New (bare constructor) does not. The retry block should reflect that.
assert.Nil(t, j.Retry, "bare New(...) should not populate a retry block")
assert.Equal(t, []string{"try this"}, j.Suggestions)
assert.Equal(t, []string{"queries[0]"}, j.InvalidReferences)
}
func TestAsJSONRetryBlock(t *testing.T) {
t.Run("RetryAfterIncludesDuration", func(t *testing.T) {
err := NewTimeoutf(MustNewCode("slow"), "slow").WithRetryAfter(5 * time.Second)
j := AsJSON(err)
require.NotNil(t, j.Retry)
assert.Equal(t, 5*time.Second, j.Retry.Delay)
})
t.Run("NonAfterPolicyOmitsDurationField", func(t *testing.T) {
// NewInvalidInputf auto-applies retryAfterFix via the constructor helper.
err := NewInvalidInputf(MustNewCode("bad"), "bad")
j := AsJSON(err)
require.Nil(t, j.Retry, "retry must be empty")
})
t.Run("BareErrorOmitsRetryBlock", func(t *testing.T) {
err := New(TypeInternal, MustNewCode("boom"), "boom")
j := AsJSON(err)
assert.Nil(t, j.Retry, "bare New(...) without WithRetry* must omit retry")
})
t.Run("NonBaseErrorOmitsRetryBlock", func(t *testing.T) {
// Stdlib errors carry no retry metadata; AsJSON omits the retry block.
j := AsJSON(errors.New("plain stdlib error"))
assert.Nil(t, j.Retry, "non-base errors must omit the retry block")
})
}
func TestAsJSONOptionalFieldsOmittedWhenEmpty(t *testing.T) {
j := AsJSON(New(TypeInternal, MustNewCode("boom"), "boom"))
assert.Nil(t, j.Suggestions, "no suggestions set => Suggestions must be nil so json omitempty drops it")
assert.Nil(t, j.InvalidReferences, "no invalid references set => InvalidReferences must be nil so json omitempty drops it")
}
func TestWithStacktrace(t *testing.T) {
err := New(TypeInternal, MustNewCode("test_code"), "panic").WithStacktrace("custom stack trace")

View File

@@ -3,13 +3,22 @@ package errors
import (
"encoding/json"
"net/url"
"time"
)
type JSON struct {
Code string `json:"code" required:"true"`
Message string `json:"message" required:"true"`
Url string `json:"url,omitempty"`
Errors []responseerroradditional `json:"errors,omitempty"`
Type string `json:"type,omitempty"`
Code string `json:"code" required:"true"`
Message string `json:"message" required:"true"`
Url string `json:"url,omitempty"`
Errors []responseerroradditional `json:"errors,omitempty"`
Retry *responseretryjson `json:"retry,omitempty"`
Suggestions []string `json:"suggestions,omitempty"`
InvalidReferences []string `json:"invalidReferences,omitempty"`
}
type responseretryjson struct {
Delay time.Duration `json:"delay"`
}
type responseerroradditional struct {
@@ -18,18 +27,27 @@ type responseerroradditional struct {
func AsJSON(cause error) *JSON {
// See if this is an instance of the base error or not
_, c, m, _, u, a := Unwrapb(cause)
t, c, m, _, u, a := Unwrapb(cause)
rea := make([]responseerroradditional, len(a))
for k, v := range a {
rea[k] = responseerroradditional{v}
}
var retry *responseretryjson
if r := retryOf(cause); r != nil {
retry = &responseretryjson{Delay: r.delay}
}
return &JSON{
Code: c.String(),
Message: m,
Url: u,
Errors: rea,
Type: t.String(),
Code: c.String(),
Message: m,
Url: u,
Errors: rea,
Retry: retry,
Suggestions: suggestionsOf(cause),
InvalidReferences: invalidReferencesOf(cause),
}
}

16
pkg/errors/retry.go Normal file
View File

@@ -0,0 +1,16 @@
package errors
import (
"time"
)
type retry struct {
delay time.Duration
}
// newRetryAfter builds a retry value carrying the given delay.
func newRetryAfter(d time.Duration) retry {
return retry{
delay: d,
}
}

View File

@@ -11,8 +11,7 @@ var (
TypeForbidden = typ{"forbidden"}
TypeCanceled = typ{"canceled"}
TypeTimeout = typ{"timeout"}
TypeUnexpected = typ{"unexpected"} // Generic mismatch of expectations
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
TypeFatal = typ{"fatal"} // Unrecoverable failure (e.g. panic)
TypeLicenseUnavailable = typ{"license-unavailable"}
TypeTooManyRequests = typ{"too-many-requests"}
)

View File

@@ -1,7 +1,9 @@
package render
import (
"math"
"net/http"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
jsoniter "github.com/json-iterator/go"
@@ -121,6 +123,13 @@ func Error(rw http.ResponseWriter, cause error) {
return
}
// Retry-After carries the explicit delay declared via
// errors.WithRetryAfter. Set it before WriteHeader so headers go on the wire.
d := errors.RetryDelayOf(cause)
if d.Seconds() > 0 {
rw.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(d.Seconds()))))
}
rw.WriteHeader(httpCode)
_, _ = rw.Write(body)
}

View File

@@ -6,7 +6,9 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/stretchr/testify/assert"
@@ -97,13 +99,13 @@ func TestError(t *testing.T) {
name: "AlreadyExists",
statusCode: http.StatusConflict,
err: errors.New(errors.TypeAlreadyExists, errors.MustNewCode("already_exists"), "already exists").WithUrl("https://already_exists"),
expected: []byte(`{"status":"error","error":{"code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
expected: []byte(`{"status":"error","error":{"type":"already-exists","code":"already_exists","message":"already exists","url":"https://already_exists"}}`),
},
"/unauthenticated": {
name: "Unauthenticated",
statusCode: http.StatusUnauthorized,
err: errors.New(errors.TypeUnauthenticated, errors.MustNewCode("not_allowed"), "not allowed").WithUrl("https://unauthenticated").WithAdditional("a1", "a2"),
expected: []byte(`{"status":"error","error":{"code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
expected: []byte(`{"status":"error","error":{"type":"unauthenticated","code":"not_allowed","message":"not allowed","url":"https://unauthenticated","errors":[{"message":"a1"},{"message":"a2"}]}}`),
},
}
@@ -145,3 +147,67 @@ func TestError(t *testing.T) {
}
}
// TestErrorRetryAfterHeader verifies that the HTTP Retry-After header is set
// when (and only when) the error declares an explicit WithRetryAfter delay.
// Other retry policies (backoff, after_fix, after_auth, never) and bare errors
// without any policy must NOT emit the header — clients ignore non-numeric or
// missing values, but emitting one wrongly would mislead retry libraries.
func TestErrorRetryAfterHeader(t *testing.T) {
testCases := map[string]struct {
name string
err error
wantRetryAfter string // expected header value; "" means header must be absent
wantBodyContains string // substring that must appear in the JSON body
wantBodyNotContains string // substring that must NOT appear in the JSON body
}{
"/with_retry_after_5s": {
name: "ExplicitDelay5Seconds",
err: errors.New(errors.TypeTooManyRequests, errors.MustNewCode("rate_limited"), "slow down").WithRetryAfter(5 * time.Second),
wantRetryAfter: "5",
wantBodyContains: `"retry":{"delay":5000000000}`,
},
"/with_retry_after_subsecond": {
name: "SubSecondRoundsUp",
err: errors.New(errors.TypeTooManyRequests, errors.MustNewCode("rate_limited"), "slow down").WithRetryAfter(500 * time.Millisecond),
wantRetryAfter: "1", // ceiling-rounded
wantBodyContains: `"delay":500000000`,
},
"/bare_no_policy": {
name: "BareErrorNoHeaderNoRetryBlock",
err: errors.New(errors.TypeInternal, errors.MustNewCode("boom"), "boom"),
wantRetryAfter: "",
wantBodyContains: `"code":"boom"`,
wantBodyNotContains: `"retry"`,
},
}
srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
if tc, ok := testCases[req.URL.Path]; ok {
Error(rw, tc.err)
}
}))
defer srv.Close()
for path, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := http.Get(srv.URL + path)
require.NoError(t, err)
defer func() { require.NoError(t, res.Body.Close()) }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
assert.Equal(t, tc.wantRetryAfter, res.Header.Get("Retry-After"),
"Retry-After header for %s", tc.name)
if tc.wantBodyContains != "" {
assert.Contains(t, string(body), tc.wantBodyContains,
"body should contain %q for %s", tc.wantBodyContains, tc.name)
}
if tc.wantBodyNotContains != "" {
assert.NotContains(t, string(body), tc.wantBodyNotContains,
"body should NOT contain %q for %s", tc.wantBodyNotContains, tc.name)
}
})
}
}

View File

@@ -202,15 +202,15 @@ func (handler *handler) exportRawDataJSONL(rowChan <-chan *qbtypes.RawRow, errCh
}
jsonBytes, err := json.Marshal(row.Data)
if err != nil {
return false, errors.NewUnexpectedf(errors.CodeInternal, "error marshaling JSON: %s", err)
return false, errors.NewInternalf(errors.CodeInternal, "error marshaling JSON: %s", err)
}
totalBytes += uint64(len(jsonBytes)) + 1
if _, err := writer.Write(jsonBytes); err != nil {
return false, errors.NewUnexpectedf(errors.CodeInternal, "error writing JSON: %s", err)
return false, errors.NewInternalf(errors.CodeInternal, "error writing JSON: %s", err)
}
if _, err := writer.Write([]byte("\n")); err != nil {
return false, errors.NewUnexpectedf(errors.CodeInternal, "error writing JSON newline: %s", err)
return false, errors.NewInternalf(errors.CodeInternal, "error writing JSON newline: %s", err)
}
if totalBytes > MaxExportBytesLimit {

View File

@@ -74,7 +74,7 @@ func (module *getter) ListDeprecatedUsersByOrgID(ctx context.Context, orgID valu
roleNames := userIDToRoleNames[user.ID]
if len(roleNames) == 0 {
return nil, errors.Newf(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found for user: %s", user.ID.String())
return nil, errors.Newf(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found for user: %s", user.ID.String())
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[roleNames[0]]
@@ -113,11 +113,11 @@ func (module *getter) GetDeprecatedUserByOrgIDAndID(ctx context.Context, orgID v
}
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
if userRoles[0].Role == nil {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
@@ -141,11 +141,11 @@ func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.Deprecate
}
if len(userRoles) == 0 {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeUserRolesNotFound, "no user roles entries found")
}
if userRoles[0].Role == nil {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
}
role := authtypes.SigNozManagedRoleToExistingLegacyRole[userRoles[0].Role.Name]
@@ -211,7 +211,7 @@ func (module *getter) GetRolesByUserID(ctx context.Context, userID valuer.UUID)
for _, ur := range userRoles {
if ur.Role == nil {
return nil, errors.New(errors.TypeUnexpected, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
return nil, errors.New(errors.TypeInternal, authtypes.ErrCodeRoleNotFound, "role not found for user role entry")
}
}
@@ -226,19 +226,6 @@ func (module *getter) GetUsersByOrgIDAndRoleID(ctx context.Context, orgID valuer
return module.store.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
}
func (module *getter) VerifyResetPasswordToken(ctx context.Context, token string) error {
resetPasswordToken, err := module.store.GetResetPasswordToken(ctx, token)
if err != nil {
return err
}
if resetPasswordToken.IsExpired() {
return errors.New(errors.TypeUnauthenticated, types.ErrCodeResetPasswordTokenExpired, "reset password token has expired")
}
return nil
}
func (module *getter) OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error {
users, err := module.GetUsersByOrgIDAndRoleID(ctx, orgID, roleID)
if err != nil {

View File

@@ -410,25 +410,6 @@ func (handler *handler) CreateResetPasswordToken(w http.ResponseWriter, r *http.
render.Success(w, http.StatusCreated, token)
}
func (handler *handler) VerifyResetPasswordToken(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
req := new(types.PostableVerifyResetPasswordToken)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
render.Error(w, err)
return
}
err := handler.getter.VerifyResetPasswordToken(ctx, req.Token)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusNoContent, nil)
}
func (handler *handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()

View File

@@ -537,7 +537,7 @@ func (module *setter) UpdatePasswordByResetPasswordToken(ctx context.Context, to
}
if resetPasswordToken.IsExpired() {
return errors.New(errors.TypeUnauthenticated, types.ErrCodeResetPasswordTokenExpired, "reset password token has expired")
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "reset password token has expired")
}
password, err := module.store.GetPassword(ctx, resetPasswordToken.PasswordID)

View File

@@ -94,9 +94,6 @@ type Getter interface {
// OnBeforeRoleDelete checks if any users are assigned to the role and rejects deletion if so.
OnBeforeRoleDelete(ctx context.Context, orgID valuer.UUID, roleID valuer.UUID) error
// VerifyResetPasswordToken checks if a reset password token exists and is not expired.
VerifyResetPasswordToken(ctx context.Context, token string) error
}
type Handler interface {
@@ -124,7 +121,6 @@ type Handler interface {
GetResetPasswordTokenDeprecated(http.ResponseWriter, *http.Request)
GetResetPasswordToken(http.ResponseWriter, *http.Request)
CreateResetPasswordToken(http.ResponseWriter, *http.Request)
VerifyResetPasswordToken(http.ResponseWriter, *http.Request)
ResetPassword(http.ResponseWriter, *http.Request)
ChangePassword(http.ResponseWriter, *http.Request)
ForgotPassword(http.ResponseWriter, *http.Request)

View File

@@ -216,7 +216,7 @@ func getOperators(ops []pipelinetypes.PipelineOperator) ([]pipelinetypes.Pipelin
func processSeverityParser(operator *pipelinetypes.PipelineOperator) error {
if operator.Type != "severity_parser" {
return errors.NewUnexpectedf(CodeInvalidOperatorType, "operator type received %s", operator.Type)
return errors.NewInternalf(CodeInvalidOperatorType, "operator type received %s", operator.Type)
}
parseFromNotNilCheck, err := fieldNotNilCheck(operator.ParseFrom)
@@ -236,7 +236,7 @@ func processSeverityParser(operator *pipelinetypes.PipelineOperator) error {
// processJSONParser converts simple JSON parser operator into multiple operators for JSONMapping of default variables
func processJSONParser(parent *pipelinetypes.PipelineOperator) ([]pipelinetypes.PipelineOperator, error) {
if parent.Type != "json_parser" {
return nil, errors.NewUnexpectedf(CodeInvalidOperatorType, "operator type received %s", parent.Type)
return nil, errors.NewInternalf(CodeInvalidOperatorType, "operator type received %s", parent.Type)
}
parseFromNotNilCheck, err := fieldNotNilCheck(parent.ParseFrom)

View File

@@ -210,6 +210,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateInstalledIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
)
}

View File

@@ -0,0 +1,130 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type cloudIntegrationRemoveCascadeDelete struct {
sqlschema sqlschema.SQLSchema
}
type ciServiceRow struct {
bun.BaseModel `bun:"table:cloud_integration_service"`
ID string `bun:"id"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Type string `bun:"type"`
Config string `bun:"config"`
CloudIntegrationID string `bun:"cloud_integration_id"`
}
func NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("ci_remove_cascade_delete"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &cloudIntegrationRemoveCascadeDelete{sqlschema: sqlschema}, nil
},
)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Up(ctx context.Context, db *bun.DB) error {
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// get all existing rows
var rows []*ciServiceRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
// get existing table
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("cloud_integration_service"))
if err != nil {
return err
}
// drop the existing table
for _, sql := range migration.sqlschema.Operator().DropTable(table) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// create new table without cascade delete FK
newTable := &sqlschema.Table{
Name: sqlschema.TableName("cloud_integration_service"),
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "config", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "cloud_integration_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("cloud_integration_id"),
ReferencedTableName: sqlschema.TableName("cloud_integration"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
}
// create table
for _, sql := range migration.sqlschema.Operator().CreateTable(newTable) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// add back existing rows
if len(rows) > 0 {
if _, err := tx.NewInsert().Model(&rows).Exec(ctx); err != nil {
return err
}
}
// create existing unique index on (cloud_integration_id, type)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "cloud_integration_service",
ColumnNames: []sqlschema.ColumnName{"cloud_integration_id", "type"},
})
for _, sql := range indexSQLs {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -29,7 +29,7 @@ func NewContextWithClaims(ctx context.Context, claims Claims) context.Context {
func ClaimsFromContext(ctx context.Context) (Claims, error) {
claims, ok := ctx.Value(claimsKey{}).(Claims)
if !ok {
return Claims{}, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
return Claims{}, errors.NewUnauthenticatedf(errors.CodeUnauthenticated, "unauthenticated")
}
return claims, nil
@@ -42,7 +42,7 @@ func NewContextWithAccessToken(ctx context.Context, accessToken string) context.
func AccessTokenFromContext(ctx context.Context) (string, error) {
accessToken, ok := ctx.Value(accessTokenKey{}).(string)
if !ok {
return "", errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
return "", errors.NewUnauthenticatedf(errors.CodeUnauthenticated, "unauthenticated")
}
return accessToken, nil
@@ -55,7 +55,7 @@ func NewContextWithAPIKey(ctx context.Context, apiKey string) context.Context {
func APIKeyFromContext(ctx context.Context) (string, error) {
apiKey, ok := ctx.Value(apiKeyKey{}).(string)
if !ok {
return "", errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "unauthenticated")
return "", errors.NewUnauthenticatedf(errors.CodeUnauthenticated, "unauthenticated")
}
return apiKey, nil
@@ -77,7 +77,7 @@ func (c *Claims) IsSelfAccess(id string) error {
return nil
}
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "only the user/admin can access their own resource")
return errors.NewForbiddenf(errors.CodeForbidden, "only the user/admin can access their own resource")
}
func (c *Claims) IdentityID() string {

View File

@@ -21,15 +21,10 @@ var (
ErrCodeResetPasswordTokenAlreadyExists = errors.MustNewCode("reset_password_token_already_exists")
ErrCodePasswordNotFound = errors.MustNewCode("password_not_found")
ErrCodeResetPasswordTokenNotFound = errors.MustNewCode("reset_password_token_not_found")
ErrCodeResetPasswordTokenExpired = errors.MustNewCode("reset_password_token_expired")
ErrCodePasswordAlreadyExists = errors.MustNewCode("password_already_exists")
ErrCodeIncorrectPassword = errors.MustNewCode("incorrect_password")
)
type PostableVerifyResetPasswordToken struct {
Token string `json:"token"`
}
type PostableResetPassword struct {
Password string `json:"password"`
Token string `json:"token"`

View File

@@ -23,6 +23,8 @@ type Config struct {
type SettingsConfig struct {
Posthog PosthogConfig `mapstructure:"posthog"`
Appcues AppcuesConfig `mapstructure:"appcues"`
Sentry SentryConfig `mapstructure:"sentry"`
Pylon PylonConfig `mapstructure:"sentry"`
}
type PosthogConfig struct {
@@ -33,6 +35,14 @@ type AppcuesConfig struct {
Enabled bool `mapstructure:"enabled"`
}
type SentryConfig struct {
Enabled bool `mapstructure:"enabled"`
}
type PylonConfig struct {
Enabled bool `mapstructure:"enabled"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("web"), newConfig)
}
@@ -49,6 +59,12 @@ func newConfig() factory.Config {
Appcues: AppcuesConfig{
Enabled: true,
},
Sentry: SentryConfig{
Enabled: true,
},
Pylon: PylonConfig{
Enabled: true,
},
},
}
}

View File

@@ -3,6 +3,8 @@ package web
type Settings struct {
Posthog Posthog `json:"posthog" required:"true"`
Appcues Appcues `json:"appcues" required:"true"`
Sentry Sentry `json:"sentry" required:"true"`
Pylon Pylon `json:"pylon" required:"true"`
}
type Posthog struct {
@@ -13,6 +15,14 @@ type Appcues struct {
Enabled bool `json:"enabled" required:"true"`
}
type Sentry struct {
Enabled bool `json:"enabled" required:"true"`
}
type Pylon struct {
Enabled bool `json:"enabled" required:"true"`
}
func NewSettings(config Config) Settings {
return Settings{
Posthog: Posthog{
@@ -21,5 +31,11 @@ func NewSettings(config Config) Settings {
Appcues: Appcues{
Enabled: config.Settings.Appcues.Enabled,
},
Sentry: Sentry{
Enabled: config.Settings.Sentry.Enabled,
},
Pylon: Pylon{
Enabled: config.Settings.Pylon.Enabled,
},
}
}