diff --git a/cdk/resources/LastSeen.ts b/cdk/resources/LastSeen.ts new file mode 100644 index 000000000..903d679f7 --- /dev/null +++ b/cdk/resources/LastSeen.ts @@ -0,0 +1,85 @@ +import { + aws_dynamodb as DynamoDB, + aws_iam as IAM, + aws_iot as IoT, + RemovalPolicy, + Stack, +} from 'aws-cdk-lib' +import { Construct } from 'constructs' + +/** + * Record the timestamp when the device was last seen + */ +export class LastSeen extends Construct { + public readonly table: DynamoDB.ITable + public constructor(parent: Construct) { + super(parent, 'lastSeen') + + this.table = new DynamoDB.Table(this, 'table', { + billingMode: DynamoDB.BillingMode.PAY_PER_REQUEST, + partitionKey: { + name: 'deviceId', + type: DynamoDB.AttributeType.STRING, + }, + sortKey: { + name: 'source', + type: DynamoDB.AttributeType.STRING, + }, + pointInTimeRecovery: true, + removalPolicy: RemovalPolicy.DESTROY, + }) + + const role = new IAM.Role(this, 'role', { + assumedBy: new IAM.ServicePrincipal( + 'iot.amazonaws.com', + ) as IAM.IPrincipal, + inlinePolicies: { + rootPermissions: new IAM.PolicyDocument({ + statements: [ + new IAM.PolicyStatement({ + actions: ['iot:Publish'], + resources: [ + `arn:aws:iot:${Stack.of(this).region}:${ + Stack.of(this).account + }:topic/errors`, + ], + }), + ], + }), + }, + }) + this.table.grantWriteData(role) + + new IoT.CfnTopicRule(this, 'rule', { + topicRulePayload: { + description: `Record the timestamp when a device last sent in messages`, + ruleDisabled: false, + awsIotSqlVersion: '2016-03-23', + sql: ` + select + topic(4) as deviceId, + 'deviceMessage' as source, + parse_time("yyyy-MM-dd'T'HH:mm:ss.S'Z'", ts) as lastSeen + from 'data/+/+/+/+' + where messageType = 'DATA' + `, + actions: [ + { + dynamoDBv2: { + putItem: { + tableName: this.table.tableName, + }, + roleArn: role.roleArn, + }, + }, + ], + errorAction: { + republish: { + roleArn: role.roleArn, + topic: 'errors', + }, + }, + }, + }) + } +} diff --git a/cdk/resources/WebsocketAPI.ts b/cdk/resources/WebsocketAPI.ts index b7f5d6cbd..2a7abbee0 100644 --- a/cdk/resources/WebsocketAPI.ts +++ b/cdk/resources/WebsocketAPI.ts @@ -15,6 +15,7 @@ import type { PackedLambda } from '../helpers/lambdas/packLambda' import { ApiLogging } from './ApiLogging.js' import type { DeviceStorage } from './DeviceStorage.js' import { LambdaSource } from './LambdaSource.js' +import type { LastSeen } from './LastSeen' export const integrationUri = ( parent: Construct, @@ -38,6 +39,7 @@ export class WebsocketAPI extends Construct { deviceStorage, lambdaSources, layers, + lastSeen, }: { deviceStorage: DeviceStorage lambdaSources: { @@ -48,6 +50,7 @@ export class WebsocketAPI extends Construct { publishToWebsocketClients: PackedLambda } layers: Lambda.ILayerVersion[] + lastSeen: LastSeen }, ) { super(parent, 'WebsocketAPI') @@ -86,12 +89,14 @@ export class WebsocketAPI extends Construct { LOG_LEVEL: this.node.tryGetContext('logLevel'), NODE_NO_WARNINGS: '1', DISABLE_METRICS: this.node.tryGetContext('isTest') === true ? '1' : '0', + LAST_SEEN_TABLE_NAME: lastSeen.table.tableName, }, layers, logRetention: Logs.RetentionDays.ONE_WEEK, }) this.eventBus.grantPutEventsTo(onConnect) this.connectionsTable.grantWriteData(onConnect) + lastSeen.table.grantReadData(onConnect) // onMessage const onMessage = new Lambda.Function(this, 'onMessage', { diff --git a/cdk/stacks/BackendStack.ts b/cdk/stacks/BackendStack.ts index 63eff565c..184e91f26 100644 --- a/cdk/stacks/BackendStack.ts +++ b/cdk/stacks/BackendStack.ts @@ -21,6 +21,7 @@ import { } from '../resources/Integration.js' import { parameterStoreLayerARN } from '../resources/LambdaExtensionLayers.js' import { LambdaSource } from '../resources/LambdaSource.js' +import { LastSeen } from '../resources/LastSeen.js' import { WebsocketAPI } from '../resources/WebsocketAPI.js' import { STACK_NAME } from './stackConfig.js' @@ -98,10 +99,13 @@ export class BackendStack extends Stack { const deviceStorage = new DeviceStorage(this) + const lastSeen = new LastSeen(this) + const websocketAPI = new WebsocketAPI(this, { lambdaSources, deviceStorage, layers: lambdaLayers, + lastSeen, }) new DeviceShadow(this, { diff --git a/features/LastSeen.feature.md b/features/LastSeen.feature.md new file mode 100644 index 000000000..581b1d79c --- /dev/null +++ b/features/LastSeen.feature.md @@ -0,0 +1,43 @@ +# Last seen + +> I should receive a timestamp when the device last sent in data to the cloud so +> I can determine if the device is active. + +## Background + +Given I have the fingerprint for a `PCA20035+solar` device in `fingerprint` + + + +And I store `$floor($millis()/1000)*1000` into `ts` + +And the device `${fingerprint:deviceId}` publishes this message to the topic +`m/d/${fingerprint:deviceId}/d2c` + +```json +{ + "appId": "SOLAR", + "messageType": "DATA", + "ts": ${ts}, + "data": "3.123456" +} +``` + +## Retrieve last seen timestamp on connect + +Given I store `$fromMillis(${ts})` into `tsISO` + +When I connect to the websocket using fingerprint `${fingerprint}` + + + +Soon I should receive a message on the websocket that matches + +```json +{ + "@context": "https://github.com/hello-nrfcloud/proto/deviceIdentity", + "id": "${fingerprint:deviceId}", + "model": "PCA20035+solar", + "lastSeen": "${tsISO}" +} +``` diff --git a/lambda/onConnect.ts b/lambda/onConnect.ts index 6d5323f78..7b80e789b 100644 --- a/lambda/onConnect.ts +++ b/lambda/onConnect.ts @@ -4,14 +4,16 @@ import { Context, DeviceIdentity } from '@hello.nrfcloud.com/proto/hello' import { fromEnv } from '@nordicsemiconductor/from-env' import type { Static } from '@sinclair/typebox' import type { APIGatewayProxyStructuredResultV2 } from 'aws-lambda' +import { lastSeenRepo } from '../lastSeen/lastSeenRepo.js' import { connectionsRepository } from '../websocket/connectionsRepository.js' import type { WebsocketPayload } from './publishToWebsocketClients.js' import { logger } from './util/logger.js' import type { AuthorizedEvent } from './ws/AuthorizedEvent.js' -const { EventBusName, TableName } = fromEnv({ +const { EventBusName, TableName, LastSeenTableName } = fromEnv({ EventBusName: 'EVENTBUS_NAME', TableName: 'WEBSOCKET_CONNECTIONS_TABLE_NAME', + LastSeenTableName: 'LAST_SEEN_TABLE_NAME', })(process.env) const log = logger('connect') @@ -19,6 +21,7 @@ const eventBus = new EventBridge({}) const db = new DynamoDBClient({}) const repo = connectionsRepository(db, TableName) +const { getLastSeenOrNull } = lastSeenRepo(db, LastSeenTableName) export const handler = async ( event: AuthorizedEvent, @@ -38,6 +41,7 @@ export const handler = async ( '@context': Context.deviceIdentity.toString(), model, id: deviceId, + lastSeen: (await getLastSeenOrNull(deviceId))?.toISOString() ?? undefined, } log.debug('websocket message', { message }) diff --git a/lastSeen/lastSeenRepo.ts b/lastSeen/lastSeenRepo.ts new file mode 100644 index 000000000..9c8da24aa --- /dev/null +++ b/lastSeen/lastSeenRepo.ts @@ -0,0 +1,49 @@ +import { GetItemCommand, type DynamoDBClient } from '@aws-sdk/client-dynamodb' + +export const lastSeenRepo = ( + db: DynamoDBClient, + TableName: string, +): { + getLastSeen: ( + deviceId: string, + ) => Promise<{ error: Error } | { lastSeen: Date | null }> + getLastSeenOrNull: (deviceId: string) => Promise +} => { + const getLastSeen = async ( + deviceId: string, + ): Promise<{ error: Error } | { lastSeen: Date | null }> => { + try { + const { Item } = await db.send( + new GetItemCommand({ + TableName, + Key: { + deviceId: { + S: deviceId, + }, + source: { + S: 'deviceMessage', + }, + }, + ProjectionExpression: '#lastSeen', + ExpressionAttributeNames: { + '#lastSeen': 'lastSeen', + }, + }), + ) + const lastSeen = Item?.lastSeen?.S + return { + lastSeen: lastSeen === undefined ? null : new Date(lastSeen), + } + } catch (err) { + return { error: err as Error } + } + } + + return { + getLastSeen, + getLastSeenOrNull: async (deviceId: string): Promise => { + const res = await getLastSeen(deviceId) + return 'lastSeen' in res ? res.lastSeen : null + }, + } +}