From 5bf4bde9f05698a65de323ef42a7f5ca6904a591 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 10 Apr 2025 15:41:59 +1000 Subject: [PATCH] Performance tracing with sentry --- app/Http/Controllers/CreditController.php | 2 +- .../Credit/ValidInvoiceCreditRule.php | 1 + app/Services/Template/TemplateAction.php | 2 +- app/Services/Template/TemplateService.php | 3 +- config/sentry.php | 130 +++++++++++++-- tests/Feature/CreditTest.php | 154 ++++++++++++++++++ 6 files changed, 274 insertions(+), 18 deletions(-) diff --git a/app/Http/Controllers/CreditController.php b/app/Http/Controllers/CreditController.php index ff3a1dac40..38a4de1e2c 100644 --- a/app/Http/Controllers/CreditController.php +++ b/app/Http/Controllers/CreditController.php @@ -214,7 +214,7 @@ class CreditController extends BaseController if ($credit->invoice_id) { $credit = $credit->service()->markSent()->save(); // $credit->client->service()->updatePaidToDate(-1 * $credit->balance)->save(); // If we mutate the paid to date, we need to reverse the status of the invoice, this will allow the credit note that has been created to be used and double paid to dates prevented. - $credit->client->service()->updateBalanceAndPaidToDate(-1 * $credit->balance, -1 * $credit->balance)->save(); + $credit->client->service()->updateBalanceAndPaidToDate(-1 * ($credit->invoice->balance ?? 0), -1 * $credit->balance)->save(); // $invoice = $credit->invoice; $invoice = \App\Models\Invoice::withTrashed()->find($credit->invoice_id); diff --git a/app/Http/ValidationRules/Credit/ValidInvoiceCreditRule.php b/app/Http/ValidationRules/Credit/ValidInvoiceCreditRule.php index f42937eb0b..241abef914 100644 --- a/app/Http/ValidationRules/Credit/ValidInvoiceCreditRule.php +++ b/app/Http/ValidationRules/Credit/ValidInvoiceCreditRule.php @@ -80,6 +80,7 @@ class ValidInvoiceCreditRule implements Rule $cost = 0; foreach (request()->input('line_items') as $item) { + $item = (array)$item; $cost += $item['cost'] * $item['quantity']; } diff --git a/app/Services/Template/TemplateAction.php b/app/Services/Template/TemplateAction.php index 182b7ad2bd..e4c84253af 100644 --- a/app/Services/Template/TemplateAction.php +++ b/app/Services/Template/TemplateAction.php @@ -228,7 +228,7 @@ class TemplateAction implements ShouldQueue public function middleware() { - return [new WithoutOverlapping("template-{$this->company->company_key}")]; + return [(new WithoutOverlapping('template-' . $this->company->company_key . $this->entity))->releaseAfter(60)]; } } diff --git a/app/Services/Template/TemplateService.php b/app/Services/Template/TemplateService.php index 72279397b4..f38e17bcba 100644 --- a/app/Services/Template/TemplateService.php +++ b/app/Services/Template/TemplateService.php @@ -621,6 +621,7 @@ class TemplateService 'payments' => $payments, 'total_tax_map' => $invoice->calc()->getTotalTaxMap(), 'line_tax_map' => $invoice->calc()->getTaxMap()->toArray(), + 'project' => $invoice->project ? $this->transformProject($invoice->project, true) : [], ]; }); @@ -1172,7 +1173,7 @@ class TemplateService 'client' => $this->getClient($project), 'user' => $this->userInfo($project->user), 'assigned_user' => $project->assigned_user ? $this->userInfo($project->assigned_user) : [], - 'invoices' => $this->processInvoices($project->invoices), + 'invoices' => !$nested ? $this->processInvoices($project->invoices) : [], 'expenses' => ($project->expenses && !$nested) ? $this->processExpenses($project->expenses, true) : [], ]; diff --git a/config/sentry.php b/config/sentry.php index d51a346c90..496f020d9c 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -1,29 +1,129 @@ env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')), + // @see https://docs.sentry.io/product/sentry-basics/dsn-explainer/ 'dsn' => config('ninja.sentry_dsn'), - + + // @see https://spotlightjs.com/ + // 'spotlight' => env('SENTRY_SPOTLIGHT', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger + // 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')` + + // The release version of your application + // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) 'release' => config('ninja.app_version'), - 'breadcrumbs' => [ - // Capture Laravel logs in breadcrumbs - 'logs' => true, + // When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`) + 'environment' => env('SENTRY_ENVIRONMENT'), - // Capture SQL queries in breadcrumbs - 'sql_queries' => true, + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample-rate + 'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1 : (float) env('SENTRY_SAMPLE_RATE'), - // Capture bindings on SQL queries logged in breadcrumbs - 'sql_bindings' => true, + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces-sample-rate + 'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? 0.01 : (float) env('SENTRY_TRACES_SAMPLE_RATE'), - // Capture queue job information in breadcrumbs - 'queue_info' => true, + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles-sample-rate + 'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? 0.001 : (float) env('SENTRY_PROFILES_SAMPLE_RATE'), - // Capture command information in breadcrumbs - 'command_info' => true, + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send-default-pii + 'send_default_pii' => env('SENTRY_SEND_DEFAULT_PII', false), + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-exceptions + // 'ignore_exceptions' => [], + + // @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore-transactions + 'ignore_transactions' => [ + // Ignore Laravel's default health URL + '/up', + ], + + // Breadcrumb specific configuration + 'breadcrumbs' => [ + // Capture Laravel logs as breadcrumbs + 'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as breadcrumbs + 'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', true), + + // Capture Livewire components like routes as breadcrumbs + 'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true), + + // Capture SQL queries as breadcrumbs + 'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query breadcrumbs + 'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false), + + // Capture queue job information as breadcrumbs + 'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true), + + // Capture command information as breadcrumbs + 'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true), + + // Capture HTTP client request information as breadcrumbs + 'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture send notifications as breadcrumbs + 'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true), + ], + + // Performance monitoring specific configuration + 'tracing' => [ + // Trace queue jobs as their own transactions (this enables tracing for queue jobs) + 'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true), + + // Capture queue jobs as spans when executed on the sync driver + 'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true), + + // Capture SQL queries as spans + 'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true), + + // Capture SQL query bindings (parameters) in SQL query spans + 'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false), + + // Capture where the SQL query originated from on the SQL query spans + 'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true), + + // Define a threshold in milliseconds for SQL queries to resolve their origin + 'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100), + + // Capture views rendered as spans + 'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true), + + // Capture Livewire components as spans + 'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true), + + // Capture HTTP client requests as spans + 'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true), + + // Capture Laravel cache events (hits, writes etc.) as spans + 'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true), + + // Capture Redis operations as spans (this enables Redis events in Laravel) + 'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false), + + // Capture where the Redis command originated from on the Redis command spans + 'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true), + + // Capture send notifications as spans + 'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true), + + // Enable tracing for requests without a matching route (404's) + 'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false), + + // Configures if the performance trace should continue after the response has been sent to the user until the application terminates + // This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example + 'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true), + + // Enable the tracing integrations supplied by Sentry (recommended) + 'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true), ], - // @see: https://docs.sentry.io/error-reporting/configuration/?platform=php#send-default-pii - 'send_default_pii' => false, ]; diff --git a/tests/Feature/CreditTest.php b/tests/Feature/CreditTest.php index 0316440481..46ae60caf7 100644 --- a/tests/Feature/CreditTest.php +++ b/tests/Feature/CreditTest.php @@ -43,6 +43,160 @@ class CreditTest extends TestCase $this->makeTestData(); } + public function testCreditReversalScenarioInvoicePartiallyPaid() + { + + $c = Client::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'balance' => 0, + 'paid_to_date' => 0, + ]); + + $ii = new InvoiceItem(); + $ii->cost = 100; + $ii->quantity = 1; + $ii->product_key = 'xx'; + $ii->notes = 'yy'; + + $i = \App\Models\Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $c->id, + 'tax_name1' => '', + 'tax_name2' => '', + 'tax_name3' => '', + 'tax_rate1' => 0, + 'tax_rate2' => 0, + 'tax_rate3' => 0, + 'discount' => 0, + 'line_items' => [ + $ii + ], + 'status_id' => 1, + ]); + + $i = $i->calc()->getInvoice(); + $i = $i->service()->markSent()->save(); + + $this->assertEquals(100, $i->balance); + $this->assertEquals(100, $i->amount); + + $i->service()->applyPaymentAmount(50, 'test'); + $i->refresh(); + + $this->assertEquals(50, $i->balance); + $this->assertEquals(100, $i->amount); + + $credit_array = $i->withoutRelations()->toArray(); + $credit_array['invoice_id'] = $i->hashed_id; + $credit_array['client_id'] = $c->hashed_id; + + $ii = new InvoiceItem(); + $ii->cost = 50; + $ii->quantity = 1; + $ii->product_key = 'xx'; + $ii->notes = 'yy'; + + $credit_array['line_items'] = [$ii]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/credits', $credit_array); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertEquals(50, $arr['data']['balance']); + $this->assertEquals(50, $arr['data']['amount']); + $this->assertEquals(2, $arr['data']['status_id']); + + $i->refresh(); + $c->refresh(); + + $this->assertEquals(0, $c->balance); + $this->assertEquals(6, $i->status_id); + } + + + public function testCreditReversalScenarioInvoicePaidInFull() + { + + $c = Client::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'balance' => 0, + 'paid_to_date' => 0, + ]); + + $ii = new InvoiceItem(); + $ii->cost = 100; + $ii->quantity = 1; + $ii->product_key = 'xx'; + $ii->notes = 'yy'; + + $i = \App\Models\Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $c->id, + 'tax_name1' => '', + 'tax_name2' => '', + 'tax_name3' => '', + 'tax_rate1' => 0, + 'tax_rate2' => 0, + 'tax_rate3' => 0, + 'discount' => 0, + 'line_items' => [ + $ii + ], + 'status_id' => 1, + ]); + + $i = $i->calc()->getInvoice(); + $i = $i->service()->markSent()->save(); + + $this->assertEquals(100, $i->balance); + $this->assertEquals(100, $i->amount); + + $i->service()->applyPaymentAmount(100, 'test'); + $i->refresh(); + + $this->assertEquals(0, $i->balance); + $this->assertEquals(100, $i->amount); + $this->assertEquals(4, $i->status_id); + + $credit_array = $i->withoutRelations()->toArray(); + $credit_array['invoice_id'] = $i->hashed_id; + $credit_array['client_id'] = $c->hashed_id; + + $ii = new InvoiceItem(); + $ii->cost = 100; + $ii->quantity = 1; + $ii->product_key = 'xx'; + $ii->notes = 'yy'; + + $credit_array['line_items'] = [$ii]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->post('/api/v1/credits', $credit_array); + + $response->assertStatus(200); + $arr = $response->json(); + + $this->assertEquals(100, $arr['data']['balance']); + $this->assertEquals(100, $arr['data']['amount']); + $this->assertEquals(2, $arr['data']['status_id']); + + $i->refresh(); + $c->refresh(); + + $this->assertEquals(0, $c->balance); + $this->assertEquals(6, $i->status_id); + } + public function testPaidToDateAdjustments() {