[privacy] make observability local-only

This commit is contained in:
Kunthawat Greethong
2026-08-16 02:29:29 +07:00
parent 372f3165ce
commit 19dc44969a
29 changed files with 96 additions and 541 deletions

View File

@@ -210,33 +210,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# for mobile apps
# FCM_SERVER_KEY=
### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
# ELASTIC_APM_SERVER_URL=
# ELASTIC_APM_SECRET_TOKEN=
## Sentry
# SENTRY_DSN=
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
# SCOUT_NAME=YOURAPPNAME (Production)
# SCOUT_MONITOR=true
## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# NEW_RELIC_LICENSE_KEY=
# Set this to true to allow newrelic apm to send logs.
# This is turned off by default.
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
## Datadog
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
### Remote observability
# Remote error reporting and APM agents are intentionally disabled in this build.
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=

View File

@@ -124,14 +124,6 @@ gem 'google-cloud-translate-v3', '>= 0.7.0'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
# ref application.rb
gem 'datadog', '~> 2.0', require: false
gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.19.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3.10'

View File

@@ -187,14 +187,6 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog (2.38.0)
cgi
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
libdatadog (~> 36.0.0.1.0)
libddwaf (~> 1.30.0.0.0)
logger
msgpack
datadog-ruby_core_source (3.5.3)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
@@ -263,10 +255,6 @@ GEM
zeitwerk (~> 2.6)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
elastic-apm (4.6.2)
concurrent-ruby (~> 1.0)
http (>= 3.0)
ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13)
erb (6.0.6)
@@ -315,9 +303,6 @@ GEM
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
firecrawl-sdk (1.4.1)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
@@ -418,15 +403,9 @@ GEM
logger
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
http-form_data (~> 2.2)
llhttp-ffi (~> 0.4.0)
http-accept (1.7.0)
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
@@ -501,26 +480,12 @@ GEM
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
libdatadog (36.0.0.1.0)
libdatadog (36.0.0.1.0-arm64-darwin)
libdatadog (36.0.0.1.0-x86_64-linux)
libddwaf (1.30.0.0.2)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.30.0.0.2-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.7.0)
lograge (0.14.0)
actionpack (>= 4)
@@ -570,11 +535,6 @@ GEM
net-smtp (0.3.4)
net-protocol
netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2)
newrelic_rpm (>= 8.0.0)
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
@@ -868,8 +828,6 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
scout_apm (5.3.3)
parser
scss_lint (0.60.0)
sass (~> 3.5, >= 3.5.5)
searchkick (5.5.2)
@@ -880,15 +838,6 @@ GEM
activerecord (>= 4)
activesupport (>= 4)
selectize-rails (0.12.6)
sentry-rails (5.19.0)
railties (>= 5.0)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.19.0)
sentry-ruby (~> 5.19.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shopify_api (14.8.0)
activesupport
@@ -1072,7 +1021,6 @@ DEPENDENCIES
commonmarker
csv-safe
database_cleaner
datadog (~> 2.0)
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password (= 2.2.1)
@@ -1080,7 +1028,6 @@ DEPENDENCIES
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
down
elastic-apm
email-provider-info
email_reply_trimmer
facebook-messenger
@@ -1123,8 +1070,6 @@ DEPENDENCIES
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
@@ -1161,13 +1106,9 @@ DEPENDENCIES
ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm-schema
scout_apm
scss_lint
searchkick
seed_dump
sentry-rails (>= 5.19.0)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3.10)

View File

@@ -3,7 +3,7 @@
import { unref } from 'vue';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
/**
* Vue composable that converts object keys to camelCase
@@ -17,12 +17,12 @@ export function useCamelCase(payload, options) {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
ExceptionTracker.setContext('transform-keys-error', {
payload,
options,
op: 'camelCase',
});
Sentry.captureException(e);
ExceptionTracker.captureException(e);
return payload;
}
}
@@ -39,12 +39,12 @@ export function useSnakeCase(payload, options) {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
ExceptionTracker.setContext('transform-keys-error', {
payload,
options,
op: 'snakeCase',
});
Sentry.captureException(e);
ExceptionTracker.captureException(e);
return payload;
}
}

View File

@@ -6,7 +6,7 @@ import {
messageSchema,
Selection,
} from '@chatwoot/prosemirror-schema';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
import camelcaseKeys from 'camelcase-keys';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
@@ -117,7 +117,7 @@ export function cleanSignature(signature) {
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
Sentry.captureException(e);
ExceptionTracker.captureException(e);
// The parser can break on some cases
// for example, Token type `hr` not supported by Markdown parser
return signature;

View File

@@ -16,7 +16,7 @@ import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
export default {
components: {
@@ -113,7 +113,7 @@ export default {
if (error.name === 'ScriptLoaderError') {
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
Sentry.captureException(error);
ExceptionTracker.captureException(error);
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
}

View File

@@ -5,7 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
export default {
components: {
@@ -62,7 +62,7 @@ export default {
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
ExceptionTracker.captureException(error);
useAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
}

View File

@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
import FormSelect from 'v3/components/Form/Select.vue';
const props = defineProps({
@@ -57,7 +57,7 @@ const playAudio = async () => {
audio.src = `/audio/dashboard/${selectedValue.value}.mp3`;
await audio.play();
} catch (error) {
Sentry.captureException(error);
ExceptionTracker.captureException(error);
}
};
</script>

View File

@@ -1,5 +1,5 @@
import types from '../../mutation-types';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
export const mutations = {
[types.SET_CONTACT_UI_FLAG]($state, data) {
@@ -81,11 +81,11 @@ export const mutations = {
try {
availabilityStatus = data[element.id];
} catch (error) {
Sentry.setContext('contact is undefined', {
ExceptionTracker.setContext('contact is undefined', {
records: $state.records,
data: data,
});
Sentry.captureException(error);
ExceptionTracker.captureException(error);
return;
}

View File

@@ -12,7 +12,7 @@ import {
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import ExceptionTracker from 'shared/helpers/ExceptionTracker';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
@@ -114,10 +114,10 @@ const actions = {
attachments = data.payload;
} catch (error) {
// in case of error, log the error and continue
Sentry.setContext('Conversation', {
ExceptionTracker.setContext('Conversation', {
id: conversationId,
});
Sentry.captureException(error);
ExceptionTracker.captureException(error);
} finally {
// we run the commit even if the request fails
// this ensures that the `attachment` variable is always present on chat

View File

@@ -19,7 +19,6 @@ import { createPinia } from 'pinia';
import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
import * as Sentry from '@sentry/vue';
import {
initializeAnalyticsEvents,
initializeChatwootEvents,
@@ -49,31 +48,6 @@ app.use(store);
app.use(pinia);
app.use(router);
// [VITE] Disabled this, need to renable later
if (window.errorLoggingConfig) {
Sentry.init({
app,
dsn: window.errorLoggingConfig,
denyUrls: [
// Chrome extensions
/^chrome:\/\//i,
/chrome-extension:/i,
/extensions\//i,
// Locally saved copies
/file:\/\//i,
// Safari extensions.
/safari-web-extension:/i,
/safari-extension:/i,
],
integrations: [Sentry.browserTracingIntegration({ router })],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.use(WootUiKit);
app.use(

View File

@@ -2,7 +2,6 @@ import { createApp } from 'vue';
import { createI18n } from 'vue-i18n';
import i18nMessages from 'dashboard/i18n';
import * as Sentry from '@sentry/vue';
import {
initializeAnalyticsEvents,
initializeChatwootEvents,
@@ -33,30 +32,6 @@ app.use(router);
// Vue.prototype.$emitter = emitter;
app.component('fluent-icon', FluentIcon);
if (window.errorLoggingConfig) {
Sentry.init({
app,
dsn: window.errorLoggingConfig,
denyUrls: [
// Chrome extensions
/^chrome:\/\//i,
/chrome-extension:/i,
/extensions\//i,
// Locally saved copies
/file:\/\//i,
// Safari extensions.
/safari-web-extension:/i,
/safari-extension:/i,
],
integrations: [Sentry.browserTracingIntegration({ router })],
ignoreErrors: [
'ResizeObserver loop completed with undelivered notifications',
],
});
}
initializeChatwootEvents();
initializeAnalyticsEvents();
initalizeRouter();

View File

@@ -0,0 +1,10 @@
// Community Edition exception compatibility surface.
// Intentionally local-only: callers may report a failure without creating
// network traffic or serializing user/account payloads.
const ExceptionTracker = {
setContext() {},
captureException() {},
};
export default ExceptionTracker;
export { ExceptionTracker };

View File

@@ -0,0 +1,19 @@
import ExceptionTracker from '../ExceptionTracker';
describe('ExceptionTracker', () => {
it('accepts context and exceptions without retaining or serializing payloads', () => {
const context = { accountId: 42, token: '[REDACTED]' };
const exception = new Error('local-only failure');
expect(() => {
ExceptionTracker.setContext('operation', context);
ExceptionTracker.captureException(exception);
}).not.toThrow();
expect(Object.keys(ExceptionTracker)).toEqual([
'setContext',
'captureException',
]);
expect(JSON.stringify(ExceptionTracker)).toBe('{}');
});
});

View File

@@ -2,8 +2,6 @@ class Conversations::UnreadCounts::FilteredCountInstrumentation
# Centralizes the rollout-critical filtered unread count signals:
# API response duration, counter duration, snapshot build duration, snapshot state distribution,
# refresh claim rate, build lock acquisition rate, and invalidation/version bump rate.
EVENT_NAME = 'FilteredUnreadCounts'.freeze
METRIC_PREFIX = 'Custom/Conversations/UnreadCounts/Filtered'.freeze
SUMMARY_KEY = :filtered_unread_counts_request_summary
AGGREGATED_INCREMENT_OPERATIONS = %i[snapshot_state refresh_claim build_lock].freeze
SNAPSHOT_STATUSES = %i[fresh stale missing expired].freeze
@@ -65,17 +63,6 @@ class Conversations::UnreadCounts::FilteredCountInstrumentation
def increment(operation, attributes = {})
record_increment_summary(operation, attributes) if aggregated_increment?(operation)
record_event(operation, attributes) unless aggregated_increment?(operation)
record_metric("#{metric_name(operation)}/count", 1)
end
def record_event(operation, attributes = {})
agent = new_relic_agent
return unless agent.respond_to?(:record_custom_event)
agent.record_custom_event(EVENT_NAME, sanitized_attributes(attributes.merge(operation: operation)))
rescue StandardError
nil
end
private
@@ -83,28 +70,12 @@ class Conversations::UnreadCounts::FilteredCountInstrumentation
def record_observation(operation, attributes, started_at, status:)
duration_ms = elapsed_ms_since(started_at)
record_observation_summary(operation, attributes, status: status)
record_metric("#{metric_name(operation)}/duration_ms", duration_ms)
duration_ms
end
def record_request_summary(summary, status, started_at)
duration_ms = elapsed_ms_since(started_at)
summary[:status] = status
summary[:duration_ms] = duration_ms
record_metric("#{metric_name(:api_response)}/duration_ms", duration_ms)
record_event(:request_summary, summary)
end
def record_metric(name, value)
agent = new_relic_agent
return unless agent.respond_to?(:record_metric)
agent.record_metric(name, value)
rescue StandardError
nil
end
def metric_name(operation)
"#{METRIC_PREFIX}/#{operation}"
summary[:duration_ms] = elapsed_ms_since(started_at)
end
def request_summary(account_id)
@@ -160,17 +131,6 @@ class Conversations::UnreadCounts::FilteredCountInstrumentation
current_summary[key] = current_summary.fetch(key, 0) + 1
end
def sanitized_attributes(attributes)
attributes.compact.transform_values do |value|
case value
when String, Integer, Float, TrueClass, FalseClass
value
else
value.to_s
end
end
end
def elapsed_ms_since(started_at)
((monotonic_time - started_at) * 1000).round(2)
end
@@ -178,11 +138,5 @@ class Conversations::UnreadCounts::FilteredCountInstrumentation
def monotonic_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def new_relic_agent
return unless defined?(::NewRelic::Agent)
::NewRelic::Agent
end
end
end

View File

@@ -62,7 +62,6 @@
window.browserConfig = {
browser_name: '<%= browser.name %>',
}
window.errorLoggingConfig = '<%= ENV.fetch('SENTRY_FRONTEND_DSN', '') || ENV.fetch('SENTRY_DSN', '') %>'
</script>
<%= vite_client_tag %>
<%= vite_javascript_tag @application_pack %>

View File

@@ -8,24 +8,8 @@ require 'rails/all'
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
## Load the specific APM agent
# We rely on DOTENV to load the environment variables
# We need these environment variables to load the specific APM agent
## Load application dependencies
Dotenv::Rails.load
require 'datadog' if ENV.fetch('DD_TRACE_AGENT_URL', false).present?
require 'elastic-apm' if ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present?
require 'scout_apm' if ENV.fetch('SCOUT_KEY', false).present?
if ENV.fetch('NEW_RELIC_LICENSE_KEY', false).present?
require 'newrelic-sidekiq-metrics'
require 'newrelic_rpm'
end
if ENV.fetch('SENTRY_DSN', false).present?
require 'sentry-ruby'
require 'sentry-rails'
require 'sentry-sidekiq'
end
# heroku autoscaling
if ENV.fetch('JUDOSCALE_URL', false).present?

View File

@@ -1,5 +0,0 @@
# Disabled by default to prevent the agent starting up when not required
# To enable, set your ELASTIC_APM_SERVER_URL and ELASTIC_APM_SECRET_TOKEN in your .env
# Additional configuration options can be set below, as per the docs: https://www.elastic.co/guide/en/apm/agent/ruby/current/configuration.html#configuration
enabled: <%= ENV.fetch('ELASTIC_APM_SECRET_TOKEN', false).present? %>

View File

@@ -1,6 +0,0 @@
if ENV['DD_TRACE_AGENT_URL'].present?
Datadog.configure do |c|
# Instrumentation
c.tracing.instrument :rails
end
end

View File

@@ -1,15 +0,0 @@
if ENV['SENTRY_DSN'].present?
Sentry.init do |config|
config.dsn = ENV['SENTRY_DSN']
config.enabled_environments = %w[staging production]
# To activate performance monitoring, set one of these options.
# We recommend adjusting the value in production:
config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS']
config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError']
# to track post data in sentry
config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII']
end
end

View File

@@ -1,63 +0,0 @@
#
# This file configures the New Relic Agent. New Relic monitors Ruby, Java,
# .NET, PHP, Python, Node, and Go applications with deep visibility and low
# overhead. For more information, visit www.newrelic.com.
#
#
# For full documentation of agent configuration options, please refer to
# https://docs.newrelic.com/docs/agents/ruby-agent/installation-configuration/ruby-agent-configuration
common: &default_settings
# Required license key associated with your New Relic account.
license_key: <%= ENV['NEW_RELIC_LICENSE_KEY'] %>
# Your application name. Renaming here affects where data displays in New
# Relic. For more details, see https://docs.newrelic.com/docs/apm/new-relic-apm/maintenance/renaming-applications
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %>
distributed_tracing:
enabled: true
# To disable the agent regardless of other settings, uncomment the following:
agent_enabled: <%= ENV['NEW_RELIC_LICENSE_KEY'].present? && ENV.fetch('NEW_RELIC_AGENT_ENABLED', true) %>
# Logging level for log/newrelic_agent.log
log_level: <%= ENV.fetch('NEW_RELIC_LOG_LEVEL', 'info') %>
application_logging:
# If `true`, all logging-related features for the agent can be enabled or disabled
# independently. If `false`, all logging-related features are disabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_ENABLED', false) %>
forwarding:
# If `true`, the agent captures log records emitted by this application
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_FORWARDING_ENABLED', true) == "false" ? false : true %>
# Defines the maximum number of log records to buffer in memory at a time.
max_samples_stored: 30000
metrics:
# If `true`, the agent captures metrics related to logging for this application.
enabled: true
local_decorating:
# If `true`, the agent decorates logs with metadata to link to entities, hosts, traces, and spans.
# This requires a log forwarder to send your log files to New Relic.
# This should not be used when forwarding is enabled.
enabled: <%= ENV.fetch('NEW_RELIC_APPLICATION_LOGGING_DECORATING_ENABLED', false) %>
# Environment-specific settings are in this section.
# RAILS_ENV or RACK_ENV (as appropriate) is used to determine the environment.
# If your application has other named environments, configure them here.
development:
<<: *default_settings
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Development)
test:
<<: *default_settings
# It doesn't make sense to report to New Relic from automated test runs.
monitor_mode: false
staging:
<<: *default_settings
app_name: <%= ENV.fetch('NEW_RELIC_APP_NAME', 'Chatwoot') %> (Staging)
production:
<<: *default_settings

View File

@@ -1,10 +0,0 @@
common: &defaults
name: <%= ENV.fetch('SCOUT_NAME', '') %>
key: <%= ENV.fetch('SCOUT_KEY', '') %>
monitor: <%= ENV['SCOUT_KEY'].present? && ENV.fetch('SCOUT_MONITOR', true) %>
test:
monitor: <%= ENV['SCOUT_KEY'].present? && ENV.fetch('SCOUT_MONITOR', true) %>
production:
<<: *defaults

View File

@@ -1,32 +1,23 @@
###############
# One library to capture_exception and send to the specific service.
# # e as exception, u for user and a for account (user and account are optional)
# Usage: ChatwootExceptionTracker(e, user: u, account: a).capture_exception
# Local-only exception compatibility surface.
# The optional user/account arguments remain accepted for callsite compatibility,
# but are intentionally not retained, serialized, or sent anywhere.
############
class ChatwootExceptionTracker
def initialize(exception, user: nil, account: nil)
@exception = exception
@user = user
@account = account
discard_optional_context(user)
discard_optional_context(account)
end
def capture_exception
capture_exception_with_sentry if ENV['SENTRY_DSN'].present?
Rails.logger.error @exception
end
private
def capture_exception_with_sentry
Sentry.with_scope do |scope|
if @account.present?
scope.set_context('account', { id: @account.id, name: @account.name })
scope.set_tags(account_id: @account.id)
end
scope.set_user(id: @user.id, email: @user.email) if @user.is_a?(User)
Sentry.capture_exception(@exception)
end
def discard_optional_context(_value)
nil
end
end

View File

@@ -1,9 +1,9 @@
# Download Report Rake Tasks
#
# Usage:
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:agent
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:inbox
# POSTGRES_STATEMENT_TIMEOUT=600s NEW_RELIC_AGENT_ENABLED=false bundle exec rake download_report:label
# POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake download_report:agent
# POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake download_report:inbox
# POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake download_report:label
#
# The task will prompt for:
# - Account ID

View File

@@ -47,7 +47,6 @@
"@rails/actioncable": "6.1.3",
"@rails/ujs": "^7.1.400",
"@scmmishra/pico-search": "0.6.0",
"@sentry/vue": "^8.55.0",
"@sindresorhus/slugify": "2.2.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/vue-table": "^8.20.5",

76
pnpm-lock.yaml generated
View File

@@ -63,9 +63,6 @@ importers:
'@scmmishra/pico-search':
specifier: 0.6.0
version: 0.6.0
'@sentry/vue':
specifier: ^8.55.0
version: 8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2))
'@sindresorhus/slugify':
specifier: 2.2.1
version: 2.2.1
@@ -1264,40 +1261,6 @@ packages:
'@scmmishra/pico-search@0.6.0':
resolution: {integrity: sha512-1zC2cAwPWuv38VEh0It90fdUWkvX75OwBUjgTj+d5LTltARnf3ydbpcN2Ucl0aATBMmaNqPMcVvT25IOCAqCEA==}
'@sentry-internal/browser-utils@8.55.0':
resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==}
engines: {node: '>=14.18'}
'@sentry-internal/feedback@8.55.0':
resolution: {integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==}
engines: {node: '>=14.18'}
'@sentry-internal/replay-canvas@8.55.0':
resolution: {integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==}
engines: {node: '>=14.18'}
'@sentry-internal/replay@8.55.0':
resolution: {integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==}
engines: {node: '>=14.18'}
'@sentry/browser@8.55.0':
resolution: {integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==}
engines: {node: '>=14.18'}
'@sentry/core@8.55.0':
resolution: {integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==}
engines: {node: '>=14.18'}
'@sentry/vue@8.55.0':
resolution: {integrity: sha512-J6lcpzL39snV/spoGpwyk5Rp1wSFxOV4qV1NhQ9OlLHORVBp/Xpw7cEA0oKqG2w1wVtCV+gC5Jjf9HTmYiHQOQ==}
engines: {node: '>=14.18'}
peerDependencies:
pinia: 2.x || 3.x
vue: 2.x || 3.x
peerDependenciesMeta:
pinia:
optional: true
'@sindresorhus/slugify@2.2.1':
resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==}
engines: {node: '>=12'}
@@ -4972,7 +4935,6 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
@@ -5860,42 +5822,6 @@ snapshots:
'@scmmishra/pico-search@0.6.0': {}
'@sentry-internal/browser-utils@8.55.0':
dependencies:
'@sentry/core': 8.55.0
'@sentry-internal/feedback@8.55.0':
dependencies:
'@sentry/core': 8.55.0
'@sentry-internal/replay-canvas@8.55.0':
dependencies:
'@sentry-internal/replay': 8.55.0
'@sentry/core': 8.55.0
'@sentry-internal/replay@8.55.0':
dependencies:
'@sentry-internal/browser-utils': 8.55.0
'@sentry/core': 8.55.0
'@sentry/browser@8.55.0':
dependencies:
'@sentry-internal/browser-utils': 8.55.0
'@sentry-internal/feedback': 8.55.0
'@sentry-internal/replay': 8.55.0
'@sentry-internal/replay-canvas': 8.55.0
'@sentry/core': 8.55.0
'@sentry/core@8.55.0': {}
'@sentry/vue@8.55.0(pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)))(vue@3.5.12(typescript@5.6.2))':
dependencies:
'@sentry/browser': 8.55.0
'@sentry/core': 8.55.0
vue: 3.5.12(typescript@5.6.2)
optionalDependencies:
pinia: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
'@sindresorhus/slugify@2.2.1':
dependencies:
'@sindresorhus/transliterate': 1.6.0
@@ -5963,7 +5889,6 @@ snapshots:
'@types/web-bluetooth@0.0.20': {}
'@ungap/structured-clone@1.2.0': {}
'@videojs/http-streaming@2.15.1(video.js@7.21.1)':
@@ -9884,7 +9809,6 @@ snapshots:
wavesurfer.js@7.8.6: {}
webidl-conversions@7.0.0: {}
webidl-conversions@8.0.0: {}

View File

@@ -6,18 +6,28 @@ describe '/app/login', type: :request do
get '/app/login'
expect(response).to have_http_status(:success)
end
it 'does not serialize the cloud analytics token' do
allow(GlobalConfig).to receive(:get).and_return(
{ 'CLOUD_ANALYTICS_TOKEN' => 'analytics-test-token' }
{ 'CLOUD_ANALYTICS_TOKEN' => '[REDACTED]' }
)
get '/app/login'
expect(response).to have_http_status(:success)
expect(response.body).not_to include('analyticsConfig')
expect(response.body).not_to include('analytics-test-token')
expect(response.body).not_to include('[REDACTED]')
end
it 'does not serialize the frontend error reporting DSN' do
with_modified_env SENTRY_FRONTEND_DSN: 'https://sentry.invalid/frontend', SENTRY_DSN: 'https://sentry.invalid/backend' do
get '/app/login'
expect(response).to have_http_status(:success)
expect(response.body).not_to include('errorLoggingConfig')
expect(response.body).not_to include('sentry.invalid')
end
end
end
context 'with DEFAULT_LOCALE' do

View File

@@ -1,26 +1,22 @@
require 'rails_helper'
# explicitly requiring since we are loading apms conditionally in application.rb
require 'sentry-ruby'
describe ChatwootExceptionTracker do
it 'use rails logger if no tracker is configured' do
expect(Rails.logger).to receive(:error).with('random')
described_class.new('random').capture_exception
it 'writes the exception to the Rails logger' do
exception = StandardError.new('local failure')
expect(Rails.logger).to receive(:error).with(exception)
described_class.new(exception).capture_exception
end
context 'with sentry DSN' do
before do
# since sentry is not initated in test, we need to do it manually
Sentry.init do |config|
config.dsn = 'test'
end
end
it 'stays local when a Sentry DSN is present' do
exception = StandardError.new('private failure')
user = build(:user, email: 'person@example.invalid', name: 'Private User')
account = build(:account, name: 'Private Account')
it 'will call sentry capture exception' do
with_modified_env SENTRY_DSN: 'random dsn' do
expect(Sentry).to receive(:capture_exception).with('random')
described_class.new('random').capture_exception
end
with_modified_env SENTRY_DSN: 'https://sentry.invalid/project' do
expect(Rails.logger).to receive(:error).with(exception)
described_class.new(exception, user: user, account: account).capture_exception
end
end
end

View File

@@ -1,119 +1,40 @@
require 'rails_helper'
RSpec.describe Conversations::UnreadCounts::FilteredCountInstrumentation do
let(:new_relic_agent) do
Class.new do
def self.record_custom_event(*) end
def self.record_metric(*) end
end
end
before do
stub_const('NewRelic::Agent', new_relic_agent)
allow(new_relic_agent).to receive(:record_custom_event)
allow(new_relic_agent).to receive(:record_metric)
end
describe '.observe' do
it 'records duration metrics without custom events around successful operations' do
it 'returns the operation result without remote instrumentation' do
result = described_class.observe(:counter_perform, account_id: 1, snapshot_scope: :built_in_filter) { 'ok' }
expect(result).to eq('ok')
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/counter_perform/duration_ms',
kind_of(Float)
)
end
it 'records failed operations and re-raises the original error' do
it 're-raises failed operations without remote instrumentation' do
error = StandardError.new('boom')
expect do
described_class.observe(:snapshot_build, account_id: 1) { raise error }
end.to raise_error(error)
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/snapshot_build/duration_ms',
kind_of(Float)
)
end
end
describe '.increment' do
it 'records count metrics without custom events for aggregated read-path operations' do
it 'keeps local summary counters without requiring an APM agent' do
expect do
described_class.increment(:snapshot_state, account_id: 1, snapshot_status: :fresh)
expect(new_relic_agent).not_to have_received(:record_custom_event)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/snapshot_state/count',
1
)
end
it 'keeps custom events for invalidation signals' do
described_class.increment(:invalidation, account_id: 1, invalidation_scope: :conversation)
expect(new_relic_agent).to have_received(:record_custom_event).with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
invalidation_scope: 'conversation',
operation: 'invalidation'
)
)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/invalidation/count',
1
)
end
it 'does not raise when New Relic is unavailable' do
allow(described_class).to receive(:new_relic_agent).and_return(nil)
expect { described_class.increment(:snapshot_state, account_id: 1) }.not_to raise_error
end.not_to raise_error
end
end
describe '.summarize_request' do
it 'records one custom event with aggregated request counters' do
it 'returns the wrapped result while keeping request summaries local' do
result = described_class.summarize_request(account_id: 1) do
described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :built_in_filter, snapshot_status: :fresh)
described_class.increment(:snapshot_state, account_id: 1, snapshot_scope: :filter, snapshot_status: :missing)
described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: true)
described_class.increment(:refresh_claim, account_id: 1, snapshot_scope: :filter, claimed: false)
described_class.increment(:build_lock, account_id: 1, snapshot_scope: :filter, acquired: true)
described_class.observe(:snapshot_build, account_id: 1, snapshot_scope: :filter) { 'built' }
'ok'
end
expect(result).to eq('ok')
expect(new_relic_agent).to have_received(:record_custom_event).once.with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
build_lock_acquired_count: 1,
duration_ms: kind_of(Float),
filter_build_lock_acquired_count: 1,
filter_refresh_claimed_count: 1,
filter_refresh_skipped_count: 1,
filter_snapshot_build_success_count: 1,
filter_snapshot_count: 1,
operation: 'request_summary',
refresh_claimed_count: 1,
refresh_skipped_count: 1,
snapshot_build_success_count: 1,
snapshot_fresh_count: 1,
snapshot_missing_count: 1,
snapshot_total_count: 2,
status: 'success'
)
)
expect(new_relic_agent).to have_received(:record_metric).with(
'Custom/Conversations/UnreadCounts/Filtered/api_response/duration_ms',
kind_of(Float)
)
end
it 'records summary errors and re-raises the original error' do
@@ -122,16 +43,6 @@ RSpec.describe Conversations::UnreadCounts::FilteredCountInstrumentation do
expect do
described_class.summarize_request(account_id: 1) { raise error }
end.to raise_error(error)
expect(new_relic_agent).to have_received(:record_custom_event).with(
'FilteredUnreadCounts',
hash_including(
account_id: 1,
error_class: 'StandardError',
operation: 'request_summary',
status: 'error'
)
)
end
end
end