diff --git a/.envrc.local.template b/.envrc.local.template index b175c0221ee15e15641b3b36b31cb1f161a60e50..7ecaf8843225d330188d3942945f6145be190b8f 100644 --- a/.envrc.local.template +++ b/.envrc.local.template @@ -1,3 +1,4 @@ +export SENTRY_DSN= export CACHE_ENABLED=false export VOLUNTEERING_VOLTASTICS_API_URL= diff --git a/README.md b/README.md index c63f69319b80243bc220bf0f733a2c9f905ad998..f99d23ca2917a3618a8aa31b16451755101eb5a4 100644 --- a/README.md +++ b/README.md @@ -193,13 +193,19 @@ if "you know what you're doing". ### Configuration -| Environment Variable | Default Value | Description | -| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| PORT | 8004 | the port to listen on | -| CACHE_ENABLED | true | whether or not to enable caching | -| CACHE_TTL_MS_VOLTASTICS | 24 hours | time-to-live in ms for data fetched from Voltastics API | -| CACHE_TTL_MS_DB | 1 hour | time-to-live in ms for data fetched from DB | -| VOLUNTEERING_VOLTASTICS_API_URL | undefined | Voltastics API base URL | -| VOLUNTEERING_VOLTASTICS_API_KEY | undefined | Voltastics API Token | -| IMAGE_PROXY_BASE_URL | <https://images.holi.social> (production), <https://dev-images.holi.social> (all other envs) | Base URL for the image proxy server | -| GEO_API_ENDPOINT_URL | undefined | GraphQL Endpoint URL for holis Geo API | +| Environment Variable | Default Value | Description | +|---------------------------------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------| +| PORT | 8004 | the port to listen on | +| CACHE_ENABLED | true | whether or not to enable caching | +| CACHE_TTL_MS_VOLTASTICS | 24 hours | time-to-live in ms for data fetched from Voltastics API | +| CACHE_TTL_MS_DB | 1 hour | time-to-live in ms for data fetched from DB | +| VOLUNTEERING_VOLTASTICS_API_URL | undefined | Voltastics API base URL | +| VOLUNTEERING_VOLTASTICS_API_KEY | undefined | Voltastics API Token | +| IMAGE_PROXY_BASE_URL | <https://images.holi.social> (production), <https://dev-images.holi.social> (all other envs) | Base URL for the image proxy server | +| GEO_API_ENDPOINT_URL | undefined | GraphQL Endpoint URL for holis Geo API | +| DB_HOST | undefined | Recommendations DB hostname | +| DB_NAME | undefined | Recommendations DB database name | +| DB_USERNAME | undefined | Recommendations DB username | +| DB_PASSWORD | undefined | Recommendations DB password | +| DB_CONNECTION_ATTEMPTS | 10 | Recommendations DB - how often to attempt a reconnect on connection loss | +| SENTRY_DSN | undefined | The Sentry DSN to sent traces to, disabled if not given | diff --git a/app/config.ts b/app/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..88f2c07ab56763f7f803bb3bcd2fdb83b229fbb9 --- /dev/null +++ b/app/config.ts @@ -0,0 +1,116 @@ +import { VolunteeringDBConfig } from "./volunteering_db.ts"; + +export interface VoltasticsConfig { + baseUrl: string; + apiToken: string; +} + +export interface ServerConfig { + port: number; // default: 8004 + cacheEnabled: boolean; // default: true + cacheTtlMsVoltastics: number; // default: 24 hours + cacheTtlMsDB: number; // default: 1 hour + voltastics: VoltasticsConfig; + imageProxyBaseUrl: string; + fake: boolean; // For local development. If set, the API returns dummy data + volunteeringDB: VolunteeringDBConfig; + geoAPIEndpointUrl: string; +} + +export const requiredEnv = <T>( + name: string, + typeFn: (s: string) => T, + fallback?: T, +): T => { + const env = Deno.env.get(name); + if (env === undefined && fallback === undefined) { + throw Error(`Environment variable "${name}" is required`); + } else { + return env !== undefined ? typeFn(env) : fallback!; + } +}; + +const asBoolean = (str: string) => /^true$/i.test(str); + +const fake = requiredEnv("FAKE", asBoolean, false); // For local development. If set, the API returns dummy data + +export const environment = Deno.env.get("ENVIRONMENT")?.toLowerCase().trim() || "development"; + +const DEFAULT_PORT = 8004; +const DEFAULT_CACHE_ENABLED = true; +const DEFAULT_CACHE_TTL_MS_VOLTASTICS = 86_400_000; // 24 hours +const DEFAULT_CACHE_TTL_MS_DB = 3_600_000; // 1 hour + +const DEFAULT_DB_CONNECTION_ATTEMPTS = 10; + +export const serverConfigFromEnv = (): ServerConfig => { + return { + port: requiredEnv("PORT", Number, DEFAULT_PORT), + cacheEnabled: requiredEnv( + "CACHE_ENABLED", + asBoolean, + DEFAULT_CACHE_ENABLED, + ), + cacheTtlMsVoltastics: requiredEnv( + "DEFAULT_CACHE_TTL_MS_VOLTASTICS", + Number, + DEFAULT_CACHE_TTL_MS_VOLTASTICS, + ), + cacheTtlMsDB: requiredEnv( + "DEFAULT_CACHE_TTL_MS_DB", + Number, + DEFAULT_CACHE_TTL_MS_DB, + ), + voltastics: { + baseUrl: requiredEnv( + "VOLUNTEERING_VOLTASTICS_API_URL", + String, + fake ? "dummy value" : undefined, + ), + apiToken: requiredEnv( + "VOLUNTEERING_VOLTASTICS_API_KEY", + String, + fake ? "dummy value" : undefined, + ), + }, + imageProxyBaseUrl: requiredEnv( + "IMAGE_PROXY_BASE_URL", + String, + environment === "production" ? "https://images.holi.social" : "https://dev-images.holi.social", + ), + volunteeringDB: { + hostname: requiredEnv( + "DB_HOST", + String, + fake ? "dummy value" : undefined, + ), + port: requiredEnv("DB_PORT", String, fake ? "31337" : undefined), + database: requiredEnv( + "DB_NAME", + String, + fake ? "dummy value" : undefined, + ), + username: requiredEnv( + "DB_USERNAME", + String, + fake ? "dummy value" : undefined, + ), + password: requiredEnv( + "DB_PASSWORD", + String, + fake ? "dummy value" : undefined, + ), + connectionAttempts: requiredEnv( + "DB_CONNECTION_ATTEMPTS", + Number, + DEFAULT_DB_CONNECTION_ATTEMPTS, + ), + }, + fake, + geoAPIEndpointUrl: requiredEnv( + "GEO_API_ENDPOINT_URL", + String, + fake ? "dummy value" : undefined, + ), + }; +}; diff --git a/app/deps.ts b/app/deps.ts index 339c7690bc7ff1a0be5909311f52c0ab38397344..2b9a05d03cc3f0dada7879626490391409fed8cd 100644 --- a/app/deps.ts +++ b/app/deps.ts @@ -4,3 +4,4 @@ export { GraphQLError } from "npm:graphql@16.8.1"; export * as turf from "https://esm.sh/@turf/turf@6.5.0"; export { Client, type QueryObjectResult } from "https://deno.land/x/postgres/mod.ts"; export { deadline, DeadlineError } from "https://deno.land/std/async/mod.ts"; +export * as Sentry from "https://deno.land/x/sentry/index.mjs"; diff --git a/app/geo_api_client.ts b/app/geo_api_client.ts index b847481a322e423777f943b64e361ed0bf5edc1d..3089a4f7fd254a70b0603de95808e929bafab753 100644 --- a/app/geo_api_client.ts +++ b/app/geo_api_client.ts @@ -1,5 +1,6 @@ import { GeolocationCoordinates, GeolocationGeometry, GeolocationProperties } from "./types.ts"; import { logger } from "./logging.ts"; +import { Sentry } from "./deps.ts"; type GeoAPIResponse = { data?: { @@ -22,7 +23,12 @@ type GeoAPIAutocompleteResponse = { }; }; -export class GeoAPIClient { +export interface GeoAPIClient { + resolveCoordinates(geolocationId: string): Promise<GeolocationCoordinates>; + resolveGeometry(geolocationId: string, options?: { allowPoints?: boolean }): Promise<GeolocationGeometry>; +} + +export class GeoAPIClientImpl implements GeoAPIClient { private readonly geoAPIEndpointUrl: string; constructor(geoAPIEndpointUrl: string) { @@ -32,71 +38,76 @@ export class GeoAPIClient { private async resolveGeolocationViaGeoAPI( geolocationId: string, ): Promise<GeoAPIResponse> { - const graphQLQuery = `{ placeDetails(id: "${geolocationId}") { geolocation } }`; - const response = await fetch(this.geoAPIEndpointUrl, { - "body": JSON.stringify({ query: graphQLQuery }), - "headers": { - "Accept": "application/graphql-response+json, application/json", - "Content-Type": "application/json", - }, - "method": "POST", + return await Sentry.startSpan({ name: "GeoAPIClient.resolveGeolocationViaGeoAPI" }, async () => { + const graphQLQuery = `{ placeDetails(id: "${geolocationId}") { geolocation } }`; + const response = await fetch(this.geoAPIEndpointUrl, { + "body": JSON.stringify({ query: graphQLQuery }), + "headers": { + "Accept": "application/graphql-response+json, application/json", + "Content-Type": "application/json", + }, + "method": "POST", + }); + return await response.json() as GeoAPIResponse; }); - return response.json() as GeoAPIResponse; } private async resolvePlaceIdByName(name: string): Promise<string | undefined> { - const graphQLQuery = `{ placesAutocomplete(text: "${name}", limit: 1) { id } }`; - const response = await fetch(this.geoAPIEndpointUrl, { - "body": JSON.stringify({ query: graphQLQuery }), - "headers": { - "Accept": "application/graphql-response+json, application/json", - "Content-Type": "application/json", - }, - "method": "POST", + return await Sentry.startSpan({ name: "GeoAPIClient.resolvePlaceIdByName" }, async () => { + const graphQLQuery = `{ placesAutocomplete(text: "${name}", limit: 1) { id } }`; + const response = await fetch(this.geoAPIEndpointUrl, { + "body": JSON.stringify({ query: graphQLQuery }), + "headers": { + "Accept": "application/graphql-response+json, application/json", + "Content-Type": "application/json", + }, + "method": "POST", + }); + const { data } = await response.json() as GeoAPIAutocompleteResponse; + const [place] = data?.placesAutocomplete || []; + return place?.id; }); - const { data } = await response.json() as GeoAPIAutocompleteResponse; - const [place] = data?.placesAutocomplete || []; - return place?.id; } async resolveCoordinates( geolocationId: string, ): Promise<GeolocationCoordinates> { - const responseJSON = await this.resolveGeolocationViaGeoAPI(geolocationId); - - const { lat, lon } = responseJSON.data?.placeDetails?.geolocation?.properties || {}; - if (lat && lon) { - return { lat, lon }; - } else { - return Promise.reject( - `Resolution of lat/lon failed (no data included in response for geolocationId=${geolocationId})`, - ); - } + return await Sentry.startSpan({ name: "GeoAPIClient.resolveCoordinates" }, async () => { + const responseJSON = await this.resolveGeolocationViaGeoAPI(geolocationId); + const { lat, lon } = responseJSON.data?.placeDetails?.geolocation?.properties || {}; + if (lat && lon) { + return { lat, lon }; + } else { + return Promise.reject( + `Resolution of lat/lon failed (no data included in response for geolocationId=${geolocationId})`, + ); + } + }); } async resolveGeometry( geolocationId: string, - { - allowPoints = false, - }: { allowPoints?: boolean } = {}, + options: { allowPoints?: boolean } = { allowPoints: false }, ): Promise<GeolocationGeometry> { - const responseJSON = await this.resolveGeolocationViaGeoAPI(geolocationId); - const { geometry, properties } = responseJSON.data?.placeDetails?.geolocation || {}; - const { type, coordinates } = geometry || {}; - if (type && coordinates) { - const locationName = properties?.formatted; - if (type === "Point" && !allowPoints && locationName) { - logger.debug(`Retrieved point as geolocation, will retry with lookup for location ${locationName}`); - const placeId = await this.resolvePlaceIdByName(locationName); - if (placeId && placeId !== geolocationId) { - return this.resolveGeometry(placeId, { allowPoints: true }); + return await Sentry.startSpan({ name: "GeoAPIClient.resolveGeometry" }, async () => { + const responseJSON = await this.resolveGeolocationViaGeoAPI(geolocationId); + const { geometry, properties } = responseJSON.data?.placeDetails?.geolocation || {}; + const { type, coordinates } = geometry || {}; + if (type && coordinates) { + const locationName = properties?.formatted; + if (type === "Point" && !options.allowPoints && locationName) { + logger.debug(`Retrieved point as geolocation, will retry with lookup for location ${locationName}`); + const placeId = await this.resolvePlaceIdByName(locationName); + if (placeId && placeId !== geolocationId) { + return this.resolveGeometry(placeId, { allowPoints: true }); + } } + return { type, coordinates }; + } else { + return Promise.reject( + `Resolution of geolocation geometry failed (no data included in response for geolocationId=${geolocationId})`, + ); } - return { type, coordinates }; - } else { - return Promise.reject( - `Resolution of geolocation geometry failed (no data included in response for geolocationId=${geolocationId})`, - ); - } + }); } } diff --git a/app/geo_api_client_test.ts b/app/geo_api_client_test.ts index 58b71f0ebd9bb61e077d78d1265a7e5a752468bf..e81fce8cfbb4342941933bdb67c1a151de7e5552 100644 --- a/app/geo_api_client_test.ts +++ b/app/geo_api_client_test.ts @@ -1,7 +1,7 @@ import { stubFetch, stubFetchWithResponses } from "./common_test.ts"; import { assertRejects, restore } from "./dev_deps.ts"; import { assertEquals, beforeEach, describe, it } from "./dev_deps.ts"; -import { GeoAPIClient } from "./geo_api_client.ts"; +import { GeoAPIClientImpl } from "./geo_api_client.ts"; import { logger, LogSeverity } from "./logging.ts"; logger.setUpLogger( @@ -59,11 +59,11 @@ const autocompleteResponse = { const invalidResponse = {}; describe("GeoAPIClient", () => { - let client: GeoAPIClient; + let client: GeoAPIClientImpl; beforeEach(() => { restore(); - client = new GeoAPIClient(testEndpointUrl); + client = new GeoAPIClientImpl(testEndpointUrl); }); describe("resolveGeometry", () => { diff --git a/app/main.ts b/app/main.ts index de61bffe420f66ac83b35521a9e096562bd80881..bee5a70eb4276b211ee1b3b2a34b918cc93b4fd1 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1,108 +1,24 @@ import { logger, LogSeverity } from "./logging.ts"; -import { - DEFAULT_CACHE_ENABLED, - DEFAULT_CACHE_TTL_MS_DB, - DEFAULT_CACHE_TTL_MS_VOLTASTICS, - DEFAULT_DB_CONNECTION_ATTEMPTS, - DEFAULT_PORT, - ServerConfig, - startServer, -} from "./server.ts"; - -const environment = Deno.env.get("ENVIRONMENT") || "development"; +import { startServer } from "./server.ts"; +import { Sentry } from "./deps.ts"; +import { environment, serverConfigFromEnv } from "./config.ts"; logger.setUpLogger( environment, environment === "development" ? LogSeverity.DEFAULT : LogSeverity.INFO, ); -const requiredEnv = <T>( - name: string, - typeFn: (s: string) => T, - fallback?: T, -): T => { - const env = Deno.env.get(name); - if (env === undefined && fallback === undefined) { - throw Error(`Environment variable "${name}" is required`); - } else { - return env !== undefined ? typeFn(env) : fallback!; - } -}; - -const asBoolean = (str: string) => /^true$/i.test(str); - -const fake = requiredEnv("FAKE", asBoolean, false); // For local development. If set, the API returns dummy data -const serverConfigFromEnv = (): ServerConfig => { - return { - port: requiredEnv("PORT", Number, DEFAULT_PORT), - cacheEnabled: requiredEnv( - "CACHE_ENABLED", - asBoolean, - DEFAULT_CACHE_ENABLED, - ), - cacheTtlMsVoltastics: requiredEnv( - "DEFAULT_CACHE_TTL_MS_VOLTASTICS", - Number, - DEFAULT_CACHE_TTL_MS_VOLTASTICS, - ), - cacheTtlMsDB: requiredEnv( - "DEFAULT_CACHE_TTL_MS_DB", - Number, - DEFAULT_CACHE_TTL_MS_DB, - ), - voltastics: { - baseUrl: requiredEnv( - "VOLUNTEERING_VOLTASTICS_API_URL", - String, - fake ? "dummy value" : undefined, - ), - apiToken: requiredEnv( - "VOLUNTEERING_VOLTASTICS_API_KEY", - String, - fake ? "dummy value" : undefined, - ), - }, - imageProxyBaseUrl: requiredEnv( - "IMAGE_PROXY_BASE_URL", - String, - environment === "production" ? "https://images.holi.social" : "https://dev-images.holi.social", - ), - volunteeringDB: { - hostname: requiredEnv( - "DB_HOST", - String, - fake ? "dummy value" : undefined, - ), - port: requiredEnv("DB_PORT", String, fake ? "31337" : undefined), - database: requiredEnv( - "DB_NAME", - String, - fake ? "dummy value" : undefined, - ), - username: requiredEnv( - "DB_USERNAME", - String, - fake ? "dummy value" : undefined, - ), - password: requiredEnv( - "DB_PASSWORD", - String, - fake ? "dummy value" : undefined, - ), - connectionAttempts: requiredEnv( - "DB_CONNECTION_ATTEMPTS", - Number, - DEFAULT_DB_CONNECTION_ATTEMPTS, - ), - }, - fake, - geoAPIEndpointUrl: requiredEnv( - "GEO_API_ENDPOINT_URL", - String, - fake ? "dummy value" : undefined, - ), - }; -}; +const sentryDSN = Deno.env.get("SENTRY_DSN"); +if (!sentryDSN) { + logger.warn('Environment variable "SENTRY_DSN" is not set, starting without Sentry integration...'); +} else { + const TRACING_SAMPLE_RATE = 0.25; + const TRACING_SAMPLE_RATE_DEVELOPMENT = 0.0; + Sentry.init({ + dsn: sentryDSN, + tracesSampleRate: environment === "development" ? TRACING_SAMPLE_RATE_DEVELOPMENT : TRACING_SAMPLE_RATE, + environment, + }); +} -const config = serverConfigFromEnv(); -await startServer(config); +startServer(serverConfigFromEnv()); diff --git a/app/server.ts b/app/server.ts index 931c291485ee8a69ab5345eb93f9086a9109803e..3ed03f750729af5048e26b409f312b6d7c00fa30 100644 --- a/app/server.ts +++ b/app/server.ts @@ -10,12 +10,12 @@ import { TrackEngagementViewParameters, TrackEngagementViewResponse, } from "./types.ts"; -import { createSchema, createYoga, deadline, DeadlineError, useResponseCache } from "./deps.ts"; +import { Client, createSchema, createYoga, deadline, DeadlineError, Sentry, useResponseCache } from "./deps.ts"; import { fetchCategories, fetchEngagementOpportunities, trackEngagementView } from "./voltastics.ts"; import { logger } from "./logging.ts"; -import { PostgresVolunteeringDB, VolunteeringDB, VolunteeringDBConfig } from "./volunteering_db.ts"; -import { GeoAPIClient } from "./geo_api_client.ts"; -import { Client } from "https://deno.land/x/postgres@v0.19.3/client.ts"; +import { PostgresVolunteeringDB, VolunteeringDB } from "./volunteering_db.ts"; +import { GeoAPIClientImpl } from "./geo_api_client.ts"; +import { ServerConfig } from "./config.ts"; const typeDefs = ` type Organizer { @@ -144,33 +144,40 @@ const createResolvers = ( parameters: EngagementOpportunitiesParameters, ): Promise<EngagementsResponse> => config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : fetchEngagementOpportunities(config)(parameters), - engagement: ( - // deno-lint-ignore no-explicit-any - _parent: any, - parameters: EngagementParameters, - ): Promise<EngagementResponse> => config.fake ? Promise.resolve(null) : volunteeringDB.engagementById(parameters), categories: ( // deno-lint-ignore no-explicit-any _parent: any, ): Promise<CategoriesResponse> => config.fake ? Promise.resolve({ data: [] }) : fetchCategories(config.voltastics), + engagement: ( + // deno-lint-ignore no-explicit-any + _parent: any, + parameters: EngagementParameters, + ): Promise<EngagementResponse> => + config.fake ? Promise.resolve(null) : Sentry.startNewTrace(() => volunteeringDB.engagement(parameters)), filteredEngagements: ( // deno-lint-ignore no-explicit-any _parent: any, parameters: FilteredEngagementsParameters, ): Promise<EngagementsResponse> => - config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : volunteeringDB.filterEngagements(parameters), + config.fake + ? Promise.resolve({ totalResults: 0, data: [] }) + : Sentry.startNewTrace(() => volunteeringDB.filteredEngagements(parameters)), engagementRecommendations: ( // deno-lint-ignore no-explicit-any _parent: any, parameters: EngagementRecosParameters, ): Promise<EngagementsResponse> => - config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : volunteeringDB.findRecommendations(parameters), + config.fake + ? Promise.resolve({ totalResults: 0, data: [] }) + : Sentry.startNewTrace(() => volunteeringDB.engagementRecommendations(parameters)), engagementRecos: ( // deno-lint-ignore no-explicit-any _parent: any, parameters: EngagementRecosParameters, ): Promise<EngagementRecosResponse> => - config.fake ? Promise.resolve({ totalResults: 0, data: [] }) : volunteeringDB.findRecos(parameters), + config.fake + ? Promise.resolve({ totalResults: 0, data: [] }) + : Sentry.startNewTrace(() => volunteeringDB.engagementRecos(parameters)), }, Mutation: { trackEngagementView: ( @@ -182,29 +189,6 @@ const createResolvers = ( }, }); -export const DEFAULT_PORT = 8004; -export const DEFAULT_CACHE_ENABLED = true; -export const DEFAULT_CACHE_TTL_MS_VOLTASTICS = 86_400_000; // 24 hours -export const DEFAULT_CACHE_TTL_MS_DB = 3_600_000; // 1 hour - -export const DEFAULT_DB_CONNECTION_ATTEMPTS = 10; - -export interface VoltasticsConfig { - baseUrl: string; - apiToken: string; -} -export interface ServerConfig { - port: number; // default: 8004 - cacheEnabled: boolean; // default: true - cacheTtlMsVoltastics: number; // default: 24 hours - cacheTtlMsDB: number; // default: 1 hour - voltastics: VoltasticsConfig; - imageProxyBaseUrl: string; - fake: boolean; // For local development. If set, the API returns dummy data - volunteeringDB: VolunteeringDBConfig; - geoAPIEndpointUrl: string; -} - export const createGraphQLServer = (config: ServerConfig, volunteeringDB: VolunteeringDB): GraphQLServer => { const plugins = config.cacheEnabled ? [ @@ -260,7 +244,7 @@ export const startServer = (config: ServerConfig): Deno.HttpServer<Deno.NetAddr> debug: { queries: false, notices: false, - results: true, + results: false, queryInError: true, }, }, @@ -268,7 +252,7 @@ export const startServer = (config: ServerConfig): Deno.HttpServer<Deno.NetAddr> attempts: config.volunteeringDB.connectionAttempts, }, }); - const geoAPIClient = new GeoAPIClient(config.geoAPIEndpointUrl); + const geoAPIClient = new GeoAPIClientImpl(config.geoAPIEndpointUrl); const volunteeringDB = new PostgresVolunteeringDB( client, config.imageProxyBaseUrl, @@ -280,7 +264,6 @@ export const startServer = (config: ServerConfig): Deno.HttpServer<Deno.NetAddr> hostname: "0.0.0.0", handler: (req: Request) => { const url = new URL(req.url); - console.debug(url.pathname); if (url.pathname.startsWith("/liveness")) return isDbConnectionAlive(client); else return graphQLServer.handleRequest(req); }, diff --git a/app/voltastics.ts b/app/voltastics.ts index 8a6b7279cf4c3aba482d68400fcecf0b542329f1..e737eda2899cee1301df9db73f16ddcedfe16410 100644 --- a/app/voltastics.ts +++ b/app/voltastics.ts @@ -6,7 +6,6 @@ import { ApiSearchEngagementsResponse, } from "./api_types.ts"; import { logger } from "./logging.ts"; -import { ServerConfig, VoltasticsConfig } from "./server.ts"; import { CategoriesResponse, Category, @@ -18,6 +17,7 @@ import { TrackEngagementViewResponse, } from "./types.ts"; import { calculateRadius } from "./utils.ts"; +import { ServerConfig, VoltasticsConfig } from "./config.ts"; const transformOrganizer = ( engagement: ApiSearchEngagement, diff --git a/app/voltastics_test.ts b/app/voltastics_test.ts index 1ec4bc77d127a693e0486d3260bba50b5fc1f407..b8d53cf3780b3a674506d8e3363a4e11a33f2df3 100644 --- a/app/voltastics_test.ts +++ b/app/voltastics_test.ts @@ -20,7 +20,7 @@ import { EngagementsResponse, FilteredEngagementsParameters, } from "./types.ts"; -import { createGraphQLServer, GraphQLServer, ServerConfig, VoltasticsConfig } from "./server.ts"; +import { createGraphQLServer, GraphQLServer } from "./server.ts"; import { apiCategoriesResponse, @@ -32,6 +32,7 @@ import { import { fetchCategories, fetchEngagementOpportunities } from "./voltastics.ts"; import { VolunteeringDB } from "./volunteering_db.ts"; import { logger, LogSeverity } from "./logging.ts"; +import { ServerConfig, VoltasticsConfig } from "./config.ts"; logger.setUpLogger( "development", @@ -151,16 +152,16 @@ const noCacheServerConfig = { }; const fakeVolunteeringDB = new (class implements VolunteeringDB { - public engagementById(_params: EngagementParameters): Promise<EngagementResponse> { + public engagement(_params: EngagementParameters): Promise<EngagementResponse> { return Promise.reject(new Error("should not have been called")); } - findRecommendations(_params: EngagementRecosParameters): Promise<EngagementsResponse> { + engagementRecommendations(_params: EngagementRecosParameters): Promise<EngagementsResponse> { return Promise.reject(new Error("should not have been called")); } - findRecos(_params: EngagementRecosParameters): Promise<EngagementRecosResponse> { + engagementRecos(_params: EngagementRecosParameters): Promise<EngagementRecosResponse> { return Promise.reject(new Error("should not have been called")); } - filterEngagements(_params: FilteredEngagementsParameters): Promise<EngagementsResponse> { + filteredEngagements(_params: FilteredEngagementsParameters): Promise<EngagementsResponse> { return Promise.reject(new Error("should not have been called")); } })(); diff --git a/app/volunteering_db.ts b/app/volunteering_db.ts index b52a0a67f870aa2beea346aa867a25e6d3703d7a..4a1f60be5d39f1d04641ceb873c17a0e706da105 100644 --- a/app/volunteering_db.ts +++ b/app/volunteering_db.ts @@ -1,5 +1,5 @@ import type { QueryObjectResult } from "./deps.ts"; -import { Client, GraphQLError } from "./deps.ts"; +import { Client, GraphQLError, Sentry } from "./deps.ts"; import { GeoAPIClient } from "./geo_api_client.ts"; import { Engagement, @@ -312,10 +312,10 @@ const extractTotalResultCount = ( const ERROR_CODE_NOT_FOUND = "NOT_FOUND"; export interface VolunteeringDB { - engagementById(params: EngagementParameters): Promise<EngagementResponse>; - findRecommendations(params: EngagementRecosParameters): Promise<EngagementsResponse>; - findRecos(params: EngagementRecosParameters): Promise<EngagementRecosResponse>; - filterEngagements(params: FilteredEngagementsParameters): Promise<EngagementsResponse>; + engagement(params: EngagementParameters): Promise<EngagementResponse>; + engagementRecommendations(params: EngagementRecosParameters): Promise<EngagementsResponse>; + engagementRecos(params: EngagementRecosParameters): Promise<EngagementRecosResponse>; + filteredEngagements(params: FilteredEngagementsParameters): Promise<EngagementsResponse>; } export class PostgresVolunteeringDB implements VolunteeringDB { @@ -333,65 +333,77 @@ export class PostgresVolunteeringDB implements VolunteeringDB { this.geoAPIClient = geoAPIClient; } - async engagementById({ id }: EngagementParameters): Promise<EngagementResponse> { - const before = Date.now(); - const result = await this.client.queryObject<VolunteeringDBRow>` - SELECT * FROM volunteering_voltastics_with_classification - WHERE id = ${id}`; - const after = Date.now(); - logger.debug(`[${(after - before).toString().padStart(4, " ")} ms] Successfully retrieved engagement by ID ${id}`); - if (result.rows.length < 1) { - return Promise.reject( - new GraphQLError("Not found", { - extensions: { "code": ERROR_CODE_NOT_FOUND }, - }), - ); - } else { - const row = result.rows[0]; - return toEngagement(this.imageProxyBaseUrl, row); - } + async engagement({ id }: EngagementParameters): Promise<EngagementResponse> { + return await Sentry.startSpan( + { name: "VolunteeringDB.engagement" }, + async () => { + const result = await this.client.queryObject<VolunteeringDBRow>` + SELECT * FROM volunteering_voltastics_with_classification + WHERE id = ${id}`; + if (result.rows.length < 1) { + return Promise.reject( + new GraphQLError("Not found", { + extensions: { "code": ERROR_CODE_NOT_FOUND }, + }), + ); + } else { + const row = result.rows[0]; + return toEngagement(this.imageProxyBaseUrl, row); + } + }, + ); } - async findRecommendations( + async engagementRecommendations( { offset, limit, topics, skills, geolocationId }: EngagementRecosParameters, ): Promise<EngagementsResponse> { - const result = await this.queryRecos( - offset < 0 ? 0 : offset, - limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, - topics, - skills, - geolocationId, - ).catch((reason) => { - logger.error("Error retrieving recommendations. Reason: ", reason); - throw reason; - }); - const recos = result.rows.map((row) => - addQueryMatchInfo(row, toEngagement(this.imageProxyBaseUrl, row), topics, skills) + return await Sentry.startSpan( + { name: "VolunteeringDB.engagementRecommendations" }, + async () => { + const result = await this.queryRecos( + offset < 0 ? 0 : offset, + limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, + topics, + skills, + geolocationId, + ).catch((reason) => { + logger.error("Error retrieving recommendations. Reason: ", reason); + throw reason; + }); + const recos = result.rows.map((row) => + addQueryMatchInfo(row, toEngagement(this.imageProxyBaseUrl, row), topics, skills) + ); + return Promise.resolve({ + totalResults: extractTotalResultCount(result), + data: recos, + }); + }, ); - return Promise.resolve({ - totalResults: extractTotalResultCount(result), - data: recos, - }); } - async findRecos( + async engagementRecos( { offset, limit, topics, skills, geolocationId }: EngagementRecosParameters, ): Promise<EngagementRecosResponse> { - const result = await this.queryRecos( - offset < 0 ? 0 : offset, - limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, - topics, - skills, - geolocationId, - ).catch((reason) => { - logger.error("Error retrieving recommendations. Reason: ", reason); - throw reason; - }); - const recos = result.rows.map((row) => toEngagementRecommendation(this.imageProxyBaseUrl, row, topics, skills)); - return Promise.resolve({ - totalResults: extractTotalResultCount(result), - data: recos, - }); + return await Sentry.startSpan( + { name: "VolunteeringDB.engagementRecos" }, + async () => { + const result = await this.queryRecos( + offset < 0 ? 0 : offset, + limit > MAX_LIMIT || limit < 1 ? DEFAULT_LIMIT : limit, + topics, + skills, + geolocationId, + ).catch((reason) => { + logger.error("Error retrieving recommendations. Reason: ", reason); + throw reason; + }); + const recos = result.rows.map((row) => toEngagementRecommendation(this.imageProxyBaseUrl, row, topics, skills)); + return Promise.resolve({ + totalResults: extractTotalResultCount(result), + data: recos, + }); + }, + ); } private async queryRecos( @@ -401,18 +413,8 @@ export class PostgresVolunteeringDB implements VolunteeringDB { skills: string[], geolocationId?: string, ): Promise<QueryObjectResult<VolunteeringDBRow>> { - const beforeResolve = Date.now(); const geolocationCoordinates = geolocationId ? await this.geoAPIClient.resolveCoordinates(geolocationId) - .then((coordinates) => { - const afterResolve = Date.now(); - logger.debug( - `[${(afterResolve - beforeResolve).toString().padStart(4, " ")} ms] Successfully resolved ${ - JSON.stringify(coordinates) - } for geolocationId=${geolocationId}`, - ); - return coordinates; - }) .catch((error) => { logger.warn(error); if (topics.length == 0 && skills.length == 0) { @@ -425,19 +427,6 @@ export class PostgresVolunteeringDB implements VolunteeringDB { }) : undefined; - const beforeQuery = Date.now(); - const logQueryDuration = ( - result: QueryObjectResult<VolunteeringDBRow>, - ): QueryObjectResult<VolunteeringDBRow> => { - const afterQuery = Date.now(); - logger.debug( - `[${ - (afterQuery - beforeQuery).toString().padStart(4, " ") - } ms] Successfully retrieved ${result.rows.length} recommendations from DB`, - ); - return result; - }; - if (geolocationCoordinates && (topics.length > 0 || skills.length > 0)) { return this.queryRecosBasedOnTopicsSkillsAndLocation( offset, @@ -445,14 +434,12 @@ export class PostgresVolunteeringDB implements VolunteeringDB { topics, skills, geolocationCoordinates, - ).then(logQueryDuration); + ); } else if (geolocationCoordinates) { return this.queryRecosBasedOnLocation( offset, limit, geolocationCoordinates, - ).then( - logQueryDuration, ); } else if (topics.length > 0 || skills.length > 0) { return this.queryRecosBasedOnTopicsAndSkills( @@ -460,7 +447,7 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit, topics, skills, - ).then(logQueryDuration); + ); } else { return Promise.reject( "It is required to provide at least one of (topics, skills, or location) in order to retrieve recommendations", @@ -474,21 +461,23 @@ export class PostgresVolunteeringDB implements VolunteeringDB { topics: string[], skills: string[], ): Promise<QueryObjectResult<VolunteeringDBRow>> { - logger.debug( - `queryRecosBasedOnTopicsAndSkills ${JSON.stringify({ offset, limit, topics, skills })}`, + return await Sentry.startSpan( + { name: "VolunteeringDB.queryRecosBasedOnTopicsAndSkills", op: "db.query" }, + async () => { + logger.debug( + `queryRecosBasedOnTopicsAndSkills ${JSON.stringify({ offset, limit, topics, skills })}`, + ); + const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)); + return await this.client.queryObject<VolunteeringDBRow>` + WITH vector_matches AS (SELECT *, 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity + FROM volunteering_voltastics_with_classification + ORDER BY cosine_similarity DESC) + SELECT *, count(*) OVER () AS total_results + FROM vector_matches + OFFSET ${offset} LIMIT ${limit}; + `; + }, ); - const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)); - return await this.client.queryObject<VolunteeringDBRow>` - WITH vector_matches AS ( - SELECT *, 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - ) - SELECT *, count(*) OVER() AS total_results - FROM vector_matches - OFFSET ${offset} - LIMIT ${limit}; - `; } private async queryRecosBasedOnTopicsSkillsAndLocation( @@ -498,24 +487,27 @@ export class PostgresVolunteeringDB implements VolunteeringDB { skills: string[], geolocationCoordinates: GeolocationCoordinates, ): Promise<QueryObjectResult<VolunteeringDBRow>> { - logger.debug( - `queryRecosBasedOnTopicsSkillsAndLocation ${ - JSON.stringify({ offset, limit, topics, skills, geolocationCoordinates }) - }`, - ); - const rankingWeightCosineSimilarity = 0.7; // Weight for cosine similarity - const rankingWeightDistance = 0.3; // Weight for proximity - const maxDistanceInMeters = 50_000; // in meters (e.g., 50 km) - const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)); - const { lat, lon } = geolocationCoordinates; - - // Useful knowledge - // - PostGIS uses lon, lat (NOT lat, lon) - // - <#> uses index scans if used in an ORDER BY clause, ST_Distance does not - // - Database stores in GPS-Coordinates (SRID 4326) - // - In order to calculate a distance in meters, a geometry needs to be projected by transforming using - // ST_Transform(geom, 3857) where we use the SRID 3857 for Pseudo-Mercator - return await this.client.queryObject<VolunteeringDBRow>` + return await Sentry.startSpan( + { name: "VolunteeringDB.queryRecosBasedOnTopicsSkillsAndLocation", op: "db.query" }, + async () => { + logger.debug( + `queryRecosBasedOnTopicsSkillsAndLocation ${ + JSON.stringify({ offset, limit, topics, skills, geolocationCoordinates }) + }`, + ); + const rankingWeightCosineSimilarity = 0.7; // Weight for cosine similarity + const rankingWeightDistance = 0.3; // Weight for proximity + const maxDistanceInMeters = 50_000; // in meters (e.g., 50 km) + const queryVector = JSON.stringify(queryEmbeddingVector(topics, skills)); + const { lat, lon } = geolocationCoordinates; + + // Useful knowledge + // - PostGIS uses lon, lat (NOT lat, lon) + // - <#> uses index scans if used in an ORDER BY clause, ST_Distance does not + // - Database stores in GPS-Coordinates (SRID 4326) + // - In order to calculate a distance in meters, a geometry needs to be projected by transforming using + // ST_Transform(geom, 3857) where we use the SRID 3857 for Pseudo-Mercator + return await this.client.queryObject<VolunteeringDBRow>` WITH calculations AS ( SELECT *, 1 - (embedding_array <=> ${queryVector}) AS cosine_similarity, @@ -534,6 +526,8 @@ export class PostgresVolunteeringDB implements VolunteeringDB { OFFSET ${offset} LIMIT ${limit}; `; + }, + ); } private async queryRecosBasedOnLocation( @@ -541,11 +535,14 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit: number, geolocationCoordinates: GeolocationCoordinates, ): Promise<QueryObjectResult<VolunteeringDBRow>> { - logger.debug( - `queryRecosBasedOnLocation ${JSON.stringify({ offset, limit, geolocationCoordinates })}`, - ); - const { lat, lon } = geolocationCoordinates; - return await this.client.queryObject<VolunteeringDBRow>` + return await Sentry.startSpan( + { name: "VolunteeringDB.queryRecosBasedOnLocation", op: "db.query" }, + async () => { + logger.debug( + `queryRecosBasedOnLocation ${JSON.stringify({ offset, limit, geolocationCoordinates })}`, + ); + const { lat, lon } = geolocationCoordinates; + return await this.client.queryObject<VolunteeringDBRow>` SELECT *, count(*) OVER() AS total_results, ST_Transform(location_gps, 3857) <#> ST_Transform(ST_SetSRID(ST_MakePoint(${lon.toString()}, ${lat.toString()}), 4326), 3857) AS distance_in_meters @@ -554,9 +551,11 @@ export class PostgresVolunteeringDB implements VolunteeringDB { OFFSET ${offset} LIMIT ${limit}; `; + }, + ); } - async filterEngagements( + async filteredEngagements( { offset, limit, topics, skills, geolocationId }: FilteredEngagementsParameters, ): Promise<EngagementsResponse> { const geometry: GeolocationGeometry | undefined = geolocationId @@ -592,56 +591,52 @@ export class PostgresVolunteeringDB implements VolunteeringDB { limit: number, geometry?: GeolocationGeometry, ): Promise<EngagementsResponse> { - const queryVector = queryEmbeddingVector(topics, skills); - const queryVectorString = JSON.stringify(queryVector); - const nonEmptyQueryVector = topics.length + skills.length > 0; - const binaryQueryVector = queryVector.map((value) => value > 0 ? "1" : "0") - .join(""); - const emptyBinaryQueryVector = "0".repeat(EMBEDDING_DIMENSIONS); - const beforeQuery = Date.now(); - - // Filtering engagements: - // - Precomputed `binary_embedding` is a bit string marking matching topics and skills - // - Unset bits in `binary_embedding` for terms that are not part of the query using bitwise AND => If at least one set bit remains, it matches the query (checked by comparing to empty bit string, as it's too long for integer conversion) - // - Filter by geolocation by checking if coordinates are inside the given geolocation's GeoJSON geometry - // - Sort results using cosine_similarity - const result = await this.client.queryObject<VolunteeringDBRow>` - WITH vector_matches AS ( - SELECT - *, - 1 - (embedding_array <=> ${queryVectorString}) AS cosine_similarity, - CASE - WHEN ${nonEmptyQueryVector} - THEN (binary_embedding & ${binaryQueryVector}) > ${emptyBinaryQueryVector} - ELSE true - END AS matches_query, - CASE - WHEN ${!!geometry} - THEN ST_Contains(ST_GeomFromGeoJSON(${geometry}), location_gps) - ELSE true - END AS matches_location - FROM volunteering_voltastics_with_classification - ORDER BY cosine_similarity DESC - ) - SELECT *, count(*) OVER() AS total_results - FROM vector_matches - WHERE matches_query - AND matches_location - OFFSET ${offset} - LIMIT ${limit}; - `; - - const afterQuery = Date.now(); - const totalResults = extractTotalResultCount(result); - logger.debug( - `[${ - (afterQuery - beforeQuery).toString().padStart(4, " ") - } ms] Successfully fetched ${result.rows.length}/${totalResults} filtered engagements from DB`, + return await Sentry.startSpan( + { name: "VolunteeringDB.queryFilteredEngagements", op: "db.query" }, + async () => { + const queryVector = queryEmbeddingVector(topics, skills); + const queryVectorString = JSON.stringify(queryVector); + const nonEmptyQueryVector = topics.length + skills.length > 0; + const binaryQueryVector = queryVector.map((value) => value > 0 ? "1" : "0") + .join(""); + const emptyBinaryQueryVector = "0".repeat(EMBEDDING_DIMENSIONS); + + // Filtering engagements: + // - Precomputed `binary_embedding` is a bit string marking matching topics and skills + // - Unset bits in `binary_embedding` for terms that are not part of the query using bitwise AND => If at least one set bit remains, it matches the query (checked by comparing to empty bit string, as it's too long for integer conversion) + // - Filter by geolocation by checking if coordinates are inside the given geolocation's GeoJSON geometry + // - Sort results using cosine_similarity + const result = await this.client.queryObject<VolunteeringDBRow>` + WITH vector_matches AS ( + SELECT + *, + 1 - (embedding_array <=> ${queryVectorString}) AS cosine_similarity, + CASE + WHEN ${nonEmptyQueryVector} + THEN (binary_embedding & ${binaryQueryVector}) > ${emptyBinaryQueryVector} + ELSE true + END AS matches_query, + CASE + WHEN ${!!geometry} + THEN ST_Contains(ST_GeomFromGeoJSON(${geometry}), location_gps) + ELSE true + END AS matches_location + FROM volunteering_voltastics_with_classification + ORDER BY cosine_similarity DESC + ) + SELECT *, count(*) OVER() AS total_results + FROM vector_matches + WHERE matches_query + AND matches_location + OFFSET ${offset} + LIMIT ${limit}; + `; + + return { + totalResults: extractTotalResultCount(result), + data: result.rows.map((row) => toEngagement(this.imageProxyBaseUrl, row)), + }; + }, ); - - return { - totalResults, - data: result.rows.map((row) => toEngagement(this.imageProxyBaseUrl, row)), - }; } } diff --git a/app/volunteering_db_test.ts b/app/volunteering_db_test.ts index a7bdc079dac127ea243f6e3fd5df67dbd67aa320..c1edef5114ac805e02737a85e44687367e25661b 100644 --- a/app/volunteering_db_test.ts +++ b/app/volunteering_db_test.ts @@ -305,14 +305,14 @@ describe("VolunteeringDB", () => { describe("querying engagement by id", () => { it("correctly parses engagement", () => { withMockedDependencies([dbFixtures.row1])(async (volunteeringDB) => { - const response = await volunteeringDB.engagementById({ id: "1" }); + const response = await volunteeringDB.engagement({ id: "1" }); assertEngagementMatchesRow(dbFixtures.row1, response!); }); }); it("throws NOT_FOUND error for empty result", () => { withMockedDependencies([])(async (volunteeringDB) => { - const query = () => volunteeringDB.engagementById({ id: "1" }); + const query = () => volunteeringDB.engagement({ id: "1" }); await assertRejects(query, GraphQLError, "Not found"); }); }); @@ -322,7 +322,7 @@ describe("VolunteeringDB", () => { it("resolves latitude/longitude using the GeoAPI client when geolocationId is given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.findRecommendations({ + volunteeringDB.engagementRecommendations({ limit: 10, offset: 0, topics: [], @@ -338,7 +338,7 @@ describe("VolunteeringDB", () => { it("does not resolve latitude/longitude when geolocationId is not given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.findRecommendations({ + volunteeringDB.engagementRecommendations({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -357,7 +357,7 @@ describe("VolunteeringDB", () => { returnsNext([Promise.reject("boom!")]), ); - const result = await volunteeringDB.findRecommendations({ + const result = await volunteeringDB.engagementRecommendations({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -379,7 +379,7 @@ describe("VolunteeringDB", () => { ); await assertRejects( () => - volunteeringDB.findRecommendations({ + volunteeringDB.engagementRecommendations({ limit: 10, offset: 0, topics: [], @@ -396,7 +396,7 @@ describe("VolunteeringDB", () => { describe("findRecos", () => { it("wraps search results with matchedTopics, matchedSkills and matchedGeoLocationDistanceInMeters", () => { withMockedDependencies([dbFixtures.row1])(async (volunteeringDB, _geoApiClient) => { - const response = await volunteeringDB.findRecos({ + const response = await volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ["disabilities-inclusion", "water-ocean"], @@ -425,7 +425,7 @@ describe("VolunteeringDB", () => { it("resolves latitude/longitude using the GeoAPI client when geolocationId is given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.findRecos({ + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: [], @@ -441,7 +441,7 @@ describe("VolunteeringDB", () => { it("does not resolve latitude/longitude when geolocationId is not given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.findRecos({ + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -460,7 +460,7 @@ describe("VolunteeringDB", () => { returnsNext([Promise.reject("boom!")]), ); - const result = await volunteeringDB.findRecos({ + const result = await volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -482,7 +482,7 @@ describe("VolunteeringDB", () => { ); await assertRejects( () => - volunteeringDB.findRecos({ + volunteeringDB.engagementRecos({ limit: 10, offset: 0, topics: [], @@ -501,7 +501,7 @@ describe("VolunteeringDB", () => { it("resolves geolocation geometry using the GeoAPI client when geolocationId is given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.filterEngagements({ + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: [], @@ -517,7 +517,7 @@ describe("VolunteeringDB", () => { it("does not resolve geolocation geometry when geolocationId is not given", () => { withMockedDependencies()( (volunteeringDB, geoAPIClient) => { - volunteeringDB.filterEngagements({ + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -537,7 +537,7 @@ describe("VolunteeringDB", () => { ); await assertRejects( () => - volunteeringDB.filterEngagements({ + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: ["agriculture-food"], @@ -555,7 +555,7 @@ describe("VolunteeringDB", () => { async (volunteeringDB, _geoAPIClient) => { await assertRejects( () => - volunteeringDB.filterEngagements({ + volunteeringDB.filteredEngagements({ limit: 10, offset: 0, topics: [], diff --git a/deno.lock b/deno.lock index 0edcbcb380b38efcb3f1e4ca75903a76b8e8be39..1ad9d407d1463645f7d7408f7a7a3c4f3cde324a 100644 --- a/deno.lock +++ b/deno.lock @@ -118,7 +118,7 @@ "dependencies": { "@graphql-typed-document-node/core": "@graphql-typed-document-node/core@3.2.0_graphql@16.8.1", "cross-inspect": "cross-inspect@1.0.1", - "dset": "dset@3.1.3", + "dset": "dset@3.1.4", "graphql": "graphql@16.8.1", "tslib": "tslib@2.7.0" } @@ -320,8 +320,8 @@ "tslib": "tslib@2.7.0" } }, - "dset@3.1.3": { - "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==", + "dset@3.1.4": { + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", "dependencies": {} }, "fast-decode-uri-component@1.0.1": { @@ -356,7 +356,7 @@ "@graphql-yoga/subscription": "@graphql-yoga/subscription@3.1.0", "@whatwg-node/fetch": "@whatwg-node/fetch@0.8.8", "@whatwg-node/server": "@whatwg-node/server@0.7.7", - "dset": "dset@3.1.3", + "dset": "dset@3.1.4", "graphql": "graphql@16.8.1", "lru-cache": "lru-cache@7.18.3", "tslib": "tslib@2.7.0" @@ -373,7 +373,7 @@ "@graphql-yoga/subscription": "@graphql-yoga/subscription@5.0.1", "@whatwg-node/fetch": "@whatwg-node/fetch@0.9.21", "@whatwg-node/server": "@whatwg-node/server@0.9.49", - "dset": "dset@3.1.3", + "dset": "dset@3.1.4", "graphql": "graphql@16.8.1", "lru-cache": "lru-cache@10.4.3", "tslib": "tslib@2.7.0" @@ -457,7 +457,8 @@ }, "redirects": { "https://deno.land/std/async/mod.ts": "https://deno.land/std@0.224.0/async/mod.ts", - "https://deno.land/x/postgres/mod.ts": "https://deno.land/x/postgres@v0.19.3/mod.ts" + "https://deno.land/x/postgres/mod.ts": "https://deno.land/x/postgres@v0.19.3/mod.ts", + "https://deno.land/x/sentry/index.mjs": "https://deno.land/x/sentry@8.30.0/index.mjs" }, "remote": { "https://deno.land/std@0.155.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", @@ -467,17 +468,6 @@ "https://deno.land/std@0.155.0/testing/asserts.ts": "ac295f7fd22a7af107580e2475402a8c386cb1bf18bf837ae266ac0665786026", "https://deno.land/std@0.155.0/testing/bdd.ts": "35060cefd9cc21b414f4d89453b3551a3d52ec50aeff25db432503c5485b2f72", "https://deno.land/std@0.155.0/testing/mock.ts": "d7ad40139cda87476c4dc1efeffe406bbfb4a5f82b688e0b85bffe9e911526a2", - "https://deno.land/std@0.156.0/async/abortable.ts": "87aa7230be8360c24ad437212311c9e8d4328854baec27b4c7abb26e85515c06", - "https://deno.land/std@0.156.0/async/deadline.ts": "48ac998d7564969f3e6ec6b6f9bf0217ebd00239b1b2292feba61272d5dd58d0", - "https://deno.land/std@0.156.0/async/debounce.ts": "de5433bff08a2bb61416fc53b3bd2d5867090c8a815465e5b4a10a77495b1051", - "https://deno.land/std@0.156.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae", - "https://deno.land/std@0.156.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699", - "https://deno.land/std@0.156.0/async/mod.ts": "dd0a8ed4f3984ffabe2fcca7c9f466b7932d57b1864ffee148a5d5388316db6b", - "https://deno.land/std@0.156.0/async/mux_async_iterator.ts": "3447b28a2a582224a3d4d3596bccbba6e85040da3b97ed64012f7decce98d093", - "https://deno.land/std@0.156.0/async/pool.ts": "ef9eb97b388543acbf0ac32647121e4dbe629236899586c4d4311a8770fbb239", - "https://deno.land/std@0.156.0/async/tee.ts": "d27680d911816fcb3d231e16d690e7588079e66a9b2e5ce8cc354db94fdce95f", - "https://deno.land/std@0.156.0/http/server.ts": "c1bce1cbf4060055f622d5c3f0e406fd553e5dca111ca836d28c6268f170ebeb", - "https://deno.land/std@0.170.0/encoding/base64.ts": "8605e018e49211efc767686f6f687827d7f5fd5217163e981d8d693105640d7a", "https://deno.land/std@0.214.0/assert/assert.ts": "bec068b2fccdd434c138a555b19a2c2393b71dfaada02b7d568a01541e67cdc5", "https://deno.land/std@0.214.0/assert/assertion_error.ts": "9f689a101ee586c4ce92f52fa7ddd362e86434ffdf1f848e45987dc7689976b8", "https://deno.land/std@0.214.0/async/delay.ts": "8e1d18fe8b28ff95885e2bc54eccec1713f57f756053576d8228e6ca110793ad", @@ -590,8 +580,6 @@ "https://deno.land/std@0.224.0/async/pool.ts": "2b972e3643444b73f6a8bcdd19799a2d0821b28a45fbe47fd333223eb84327f0", "https://deno.land/std@0.224.0/async/retry.ts": "29025b09259c22123c599b8c957aeff2755854272954776dc9a5846c72ea4cfe", "https://deno.land/std@0.224.0/async/tee.ts": "34373c58950b7ac5950632dc8c9908076abeefcc9032d6299fff92194c284fbd", - "https://deno.land/x/cliffy@v0.25.7/ansi/ansi_escapes.ts": "885f61f343223f27b8ec69cc138a54bea30542924eacd0f290cd84edcf691387", - "https://deno.land/x/cliffy@v0.25.7/ansi/deps.ts": "0f35cb7e91868ce81561f6a77426ea8bc55dc15e13f84c7352f211023af79053", "https://deno.land/x/postgres@v0.19.3/client.ts": "d141c65c20484c545a1119c9af7a52dcc24f75c1a5633de2b9617b0f4b2ed5c1", "https://deno.land/x/postgres@v0.19.3/client/error.ts": "05b0e35d65caf0ba21f7f6fab28c0811da83cd8b4897995a2f411c2c83391036", "https://deno.land/x/postgres@v0.19.3/connection/auth.ts": "db15c1659742ef4d2791b32834950278dc7a40cb931f8e434e6569298e58df51", @@ -615,6 +603,7 @@ "https://deno.land/x/postgres@v0.19.3/query/types.ts": "540f6f973d493d63f2c0059a09f3368071f57931bba68bea408a635a3e0565d6", "https://deno.land/x/postgres@v0.19.3/utils/deferred.ts": "5420531adb6c3ea29ca8aac57b9b59bd3e4b9a938a4996bbd0947a858f611080", "https://deno.land/x/postgres@v0.19.3/utils/utils.ts": "ca47193ea03ff5b585e487a06f106d367e509263a960b787197ce0c03113a738", + "https://deno.land/x/sentry@8.30.0/index.mjs": "b043c7d7ad8a67d7c554f46743e9c8d44c673aabe1e28e51c96187b02c30e4ab", "https://esm.sh/@turf/turf@6.5.0": "1b89eb65070928dd870eb2317386f35192ccc1145242946ba7725432eb5fe547", "https://esm.sh/v135/@turf/along@6.5.0/denonext/along.mjs": "c0b37a23533598d200708d3f036820ac40979b430b5f0bc28611a7348e2a7c94", "https://esm.sh/v135/@turf/angle@6.5.0/denonext/angle.mjs": "347a7b8ad0bd322092d0625bc59e19df1fa066f78f51182c22d070eb705a0e20", diff --git a/terraform/environments/deployment.tf b/terraform/environments/deployment.tf index 00cc1e712dd31727eb0532e48a639df3d98e5298..f8a86aa22b46a42aec5d053f7a33c6d51f40123e 100644 --- a/terraform/environments/deployment.tf +++ b/terraform/environments/deployment.tf @@ -65,6 +65,10 @@ resource "google_cloud_run_service" "volunteering_api" { name = "ENVIRONMENT" value = local.environment } + env { + name = "SENTRY_DSN" + value = "https://030790361bc255b6e93a562c1a6ddba8@o949544.ingest.us.sentry.io/4507966078713856" + } env { name = "CACHE_ENABLED" value = "true"